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
|
||||
type="button"
|
||||
@click="$refs.fileInput.click()"
|
||||
class="text-blue-600 hover:text-blue-500 font-medium"
|
||||
class="text-primary-600 hover:text-primary-500 font-medium"
|
||||
>
|
||||
Click to upload
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,68 +1,40 @@
|
|||
// Central configuration for Ghost Guild Contribution Levels and Helcim Plans
|
||||
export const CONTRIBUTION_TIERS = {
|
||||
FREE: {
|
||||
value: '0',
|
||||
value: "0",
|
||||
amount: 0,
|
||||
label: '$0 - I need support right now',
|
||||
tier: 'free',
|
||||
label: "$0 - I need support right now",
|
||||
tier: "free",
|
||||
helcimPlanId: null, // No Helcim plan needed for free tier
|
||||
features: [
|
||||
'Access to basic resources',
|
||||
'Community forum access'
|
||||
]
|
||||
},
|
||||
SUPPORTER: {
|
||||
value: '5',
|
||||
value: "5",
|
||||
amount: 5,
|
||||
label: '$5 - I can contribute a little',
|
||||
tier: 'supporter',
|
||||
helcimPlanId: 'supporter-monthly-5',
|
||||
features: [
|
||||
'All Free Membership benefits',
|
||||
'Priority community support',
|
||||
'Early access to events'
|
||||
]
|
||||
label: "$5 - I can contribute",
|
||||
tier: "supporter",
|
||||
helcimPlanId: "supporter-monthly-5",
|
||||
},
|
||||
MEMBER: {
|
||||
value: '15',
|
||||
value: "15",
|
||||
amount: 15,
|
||||
label: '$15 - I can sustain the community',
|
||||
tier: 'member',
|
||||
helcimPlanId: 'member-monthly-15',
|
||||
features: [
|
||||
'All Supporter benefits',
|
||||
'Access to premium workshops',
|
||||
'Monthly 1-on-1 sessions',
|
||||
'Advanced resource library'
|
||||
]
|
||||
label: "$15 - I can sustain the community",
|
||||
tier: "member",
|
||||
helcimPlanId: "member-monthly-15",
|
||||
},
|
||||
ADVOCATE: {
|
||||
value: '30',
|
||||
value: "30",
|
||||
amount: 30,
|
||||
label: '$30 - I can support others too',
|
||||
tier: 'advocate',
|
||||
helcimPlanId: 'advocate-monthly-30',
|
||||
features: [
|
||||
'All Member benefits',
|
||||
'Weekly group mentoring',
|
||||
'Access to exclusive events',
|
||||
'Direct messaging with experts'
|
||||
]
|
||||
label: "$30 - I can support others too",
|
||||
tier: "advocate",
|
||||
helcimPlanId: "advocate-monthly-30",
|
||||
},
|
||||
CHAMPION: {
|
||||
value: '50',
|
||||
value: "50",
|
||||
amount: 50,
|
||||
label: '$50 - I want to sponsor multiple members',
|
||||
tier: 'champion',
|
||||
helcimPlanId: 'champion-monthly-50',
|
||||
features: [
|
||||
'All Advocate benefits',
|
||||
'Personal mentoring sessions',
|
||||
'VIP event access',
|
||||
'Custom project support',
|
||||
'Annual strategy session'
|
||||
]
|
||||
}
|
||||
label: "$50 - I want to sponsor multiple members",
|
||||
tier: "champion",
|
||||
helcimPlanId: "champion-monthly-50",
|
||||
},
|
||||
};
|
||||
|
||||
// Get all contribution options as an array (useful for forms)
|
||||
|
|
@ -72,12 +44,12 @@ export const getContributionOptions = () => {
|
|||
|
||||
// Get valid contribution values for validation
|
||||
export const getValidContributionValues = () => {
|
||||
return Object.values(CONTRIBUTION_TIERS).map(tier => tier.value);
|
||||
return Object.values(CONTRIBUTION_TIERS).map((tier) => tier.value);
|
||||
};
|
||||
|
||||
// Get contribution tier by value
|
||||
export const getContributionTierByValue = (value) => {
|
||||
return Object.values(CONTRIBUTION_TIERS).find(tier => tier.value === value);
|
||||
return Object.values(CONTRIBUTION_TIERS).find((tier) => tier.value === value);
|
||||
};
|
||||
|
||||
// Get Helcim plan ID for a contribution tier
|
||||
|
|
@ -99,10 +71,12 @@ export const isValidContributionValue = (value) => {
|
|||
|
||||
// Get contribution tier by Helcim plan ID
|
||||
export const getContributionTierByHelcimPlan = (helcimPlanId) => {
|
||||
return Object.values(CONTRIBUTION_TIERS).find(tier => tier.helcimPlanId === helcimPlanId);
|
||||
return Object.values(CONTRIBUTION_TIERS).find(
|
||||
(tier) => tier.helcimPlanId === helcimPlanId,
|
||||
);
|
||||
};
|
||||
|
||||
// Get paid tiers only (excluding free tier)
|
||||
export const getPaidContributionTiers = () => {
|
||||
return Object.values(CONTRIBUTION_TIERS).filter(tier => tier.amount > 0);
|
||||
return Object.values(CONTRIBUTION_TIERS).filter((tier) => tier.amount > 0);
|
||||
};
|
||||
|
|
@ -5,7 +5,10 @@
|
|||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center py-4">
|
||||
<div class="flex items-center gap-8">
|
||||
<NuxtLink to="/" class="text-xl font-bold text-gray-900 hover:text-blue-600">
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="text-xl font-bold text-gray-900 hover:text-primary"
|
||||
>
|
||||
Ghost Guild
|
||||
</NuxtLink>
|
||||
|
||||
|
|
@ -15,13 +18,28 @@
|
|||
:class="[
|
||||
'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200',
|
||||
$route.path === '/admin'
|
||||
? 'bg-blue-100 text-blue-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
|
||||
? 'bg-primary-100 text-primary-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-primary hover:bg-primary-50',
|
||||
]"
|
||||
>
|
||||
<svg class="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2 2z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3a2 2 0 012-2h4a2 2 0 012 2v4m-6 4h4"/>
|
||||
<svg
|
||||
class="w-4 h-4 inline-block mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2 2z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3a2 2 0 012-2h4a2 2 0 012 2v4m-6 4h4"
|
||||
/>
|
||||
</svg>
|
||||
Dashboard
|
||||
</NuxtLink>
|
||||
|
|
@ -31,12 +49,22 @@
|
|||
:class="[
|
||||
'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200',
|
||||
$route.path.includes('/admin/members')
|
||||
? 'bg-blue-100 text-blue-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
|
||||
? 'bg-primary-100 text-primary-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-primary hover:bg-primary-50',
|
||||
]"
|
||||
>
|
||||
<svg class="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||
<svg
|
||||
class="w-4 h-4 inline-block mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
Members
|
||||
</NuxtLink>
|
||||
|
|
@ -46,12 +74,22 @@
|
|||
:class="[
|
||||
'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200',
|
||||
$route.path.includes('/admin/events')
|
||||
? 'bg-blue-100 text-blue-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
|
||||
? 'bg-primary-100 text-primary-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-primary hover:bg-primary-50',
|
||||
]"
|
||||
>
|
||||
<svg class="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
<svg
|
||||
class="w-4 h-4 inline-block mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Events
|
||||
</NuxtLink>
|
||||
|
|
@ -61,12 +99,22 @@
|
|||
:class="[
|
||||
'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200',
|
||||
$route.path.includes('/admin/series')
|
||||
? 'bg-blue-100 text-blue-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
|
||||
? 'bg-primary-100 text-primary-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-primary hover:bg-primary-50',
|
||||
]"
|
||||
>
|
||||
<svg class="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
|
||||
<svg
|
||||
class="w-4 h-4 inline-block mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
Series
|
||||
</NuxtLink>
|
||||
|
|
@ -75,39 +123,121 @@
|
|||
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- User Menu -->
|
||||
<div class="relative" @click="showUserMenu = !showUserMenu" v-click-outside="() => showUserMenu = false">
|
||||
<button class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-100 cursor-pointer transition-colors">
|
||||
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||
<div
|
||||
class="relative"
|
||||
@click="showUserMenu = !showUserMenu"
|
||||
v-click-outside="() => (showUserMenu = false)"
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-100 cursor-pointer transition-colors"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="hidden md:block text-sm font-medium text-gray-700">Admin</span>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
<span class="hidden md:block text-sm font-medium text-gray-700"
|
||||
>Admin</span
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- User Menu Dropdown -->
|
||||
<div v-if="showUserMenu" class="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
|
||||
<NuxtLink to="/" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<svg class="w-4 h-4 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
<div
|
||||
v-if="showUserMenu"
|
||||
class="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50"
|
||||
>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 mr-3 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
View Site
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/settings" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<svg class="w-4 h-4 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
<NuxtLink
|
||||
to="/admin/settings"
|
||||
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 mr-3 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
Settings
|
||||
</NuxtLink>
|
||||
<hr class="my-1 border-gray-200">
|
||||
<button @click="logout" class="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50">
|
||||
<svg class="w-4 h-4 mr-3 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||
<hr class="my-1 border-gray-200" />
|
||||
<button
|
||||
@click="logout"
|
||||
class="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 mr-3 text-red-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
Logout
|
||||
</button>
|
||||
|
|
@ -127,8 +257,8 @@
|
|||
:class="[
|
||||
'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
|
||||
$route.path === '/admin'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-gray-600 hover:text-primary hover:bg-primary-50',
|
||||
]"
|
||||
>
|
||||
Dashboard
|
||||
|
|
@ -139,8 +269,8 @@
|
|||
:class="[
|
||||
'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
|
||||
$route.path.includes('/admin/members')
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-gray-600 hover:text-primary hover:bg-primary-50',
|
||||
]"
|
||||
>
|
||||
Members
|
||||
|
|
@ -151,8 +281,8 @@
|
|||
:class="[
|
||||
'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
|
||||
$route.path.includes('/admin/events')
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-gray-600 hover:text-primary hover:bg-primary-50',
|
||||
]"
|
||||
>
|
||||
Events
|
||||
|
|
@ -163,8 +293,8 @@
|
|||
:class="[
|
||||
'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
|
||||
$route.path.includes('/admin/series')
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-gray-600 hover:text-primary hover:bg-primary-50',
|
||||
]"
|
||||
>
|
||||
Series
|
||||
|
|
@ -192,29 +322,29 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
const showUserMenu = ref(false)
|
||||
const showUserMenu = ref(false);
|
||||
|
||||
// Close user menu when clicking outside
|
||||
const vClickOutside = {
|
||||
beforeMount(el, binding) {
|
||||
el.clickOutsideEvent = (event) => {
|
||||
if (!(el === event.target || el.contains(event.target))) {
|
||||
binding.value()
|
||||
binding.value();
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', el.clickOutsideEvent)
|
||||
};
|
||||
document.addEventListener("click", el.clickOutsideEvent);
|
||||
},
|
||||
unmounted(el) {
|
||||
document.removeEventListener('click', el.clickOutsideEvent)
|
||||
}
|
||||
}
|
||||
document.removeEventListener("click", el.clickOutsideEvent);
|
||||
},
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||
await navigateTo('/login')
|
||||
await $fetch("/api/auth/logout", { method: "POST" });
|
||||
await navigateTo("/login");
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error)
|
||||
}
|
||||
console.error("Logout failed:", error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -24,24 +24,15 @@
|
|||
</p>
|
||||
|
||||
<ul
|
||||
class="text-lg leading-relaxed text-[--ui-text-muted] space-y-3 mb-12"
|
||||
class="list-disc pl-6 text-lg leading-relaxed text-[--ui-text-muted] space-y-3 mb-12"
|
||||
>
|
||||
<li>
|
||||
<strong>Equal access:</strong> The entire knowledge commons, all
|
||||
events, and full community participation
|
||||
</li>
|
||||
<li>
|
||||
<strong>Equal voice:</strong> One member, one vote in all
|
||||
decisions
|
||||
</li>
|
||||
<li>
|
||||
<strong>Solidarity economics:</strong> Pay what you can
|
||||
($0-50+/month), take what you need
|
||||
</li>
|
||||
<li>
|
||||
<strong>Value Flow integration:</strong> Contribute your skills,
|
||||
time, and knowledge - not just money
|
||||
The entire knowledge commons, all events, and full community
|
||||
participation on our private Slack
|
||||
</li>
|
||||
<li>One member, one vote in all decisions</li>
|
||||
<li>Pay what you can ($0-50+/month)</li>
|
||||
<li>Contribute your skills, time, and knowledge</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -53,185 +44,129 @@
|
|||
<UContainer>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h2 class="text-3xl font-bold text-[--ui-text] mb-4">
|
||||
Find Your Circle
|
||||
Find your circle
|
||||
</h2>
|
||||
<p class="text-lg text-[--ui-text-muted] mb-12">
|
||||
Circles help us provide relevant guidance and connect you with
|
||||
others at similar stages. Choose based on where you are, not what
|
||||
you want to access.
|
||||
others at similar stages. Choose based on where you are now!
|
||||
</p>
|
||||
|
||||
<div class="space-y-12">
|
||||
<!-- Community Circle -->
|
||||
<div class="bg-[--ui-bg] rounded-xl p-8">
|
||||
<div class="">
|
||||
<h3 class="text-2xl font-bold text-[--ui-text] mb-2">
|
||||
Community Circle
|
||||
</h3>
|
||||
<p class="text-lg text-[--ui-text-muted] mb-6">
|
||||
You're exploring what cooperatives could mean for your work
|
||||
|
||||
<div
|
||||
class="prose prose-lg dark:prose-invert max-w-none text-[--ui-text-muted]"
|
||||
>
|
||||
<p>
|
||||
Maybe you've heard rumours about cooperatives in game dev and
|
||||
you're curious. Or you're frustrated with traditional studio
|
||||
hierarchies and wondering if there's another way. This circle
|
||||
is for anyone exploring whether cooperative principles might
|
||||
fit their work.
|
||||
</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<h4 class="text-lg font-semibold text-[--ui-text] mb-3">
|
||||
Where you might be:
|
||||
</h4>
|
||||
<ul class="text-[--ui-text-muted] space-y-2">
|
||||
<li>
|
||||
Curious about alternatives to traditional studio structures
|
||||
</li>
|
||||
<li>Researching cooperative principles</li>
|
||||
<li>Considering if a co-op is right for you</li>
|
||||
<li>Supporting the movement as an ally</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>
|
||||
This space is for you if you're: an individual game worker
|
||||
dreaming of different possibilities • a researcher digging
|
||||
into the rise of alternative studio models • an industry ally
|
||||
who wants to support cooperative work • <em>anyone</em> who's
|
||||
co-op-curious!
|
||||
</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<h4 class="text-lg font-semibold text-[--ui-text] mb-3">
|
||||
We'll help you navigate:
|
||||
</h4>
|
||||
<ul class="text-[--ui-text-muted] space-y-2">
|
||||
<li>Understanding cooperative basics</li>
|
||||
<li>Connecting with others asking similar questions</li>
|
||||
<li>Exploring real examples from game studios</li>
|
||||
<li>Deciding your next steps</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-lg font-semibold text-[--ui-text] mb-3">
|
||||
You might be:
|
||||
</h4>
|
||||
<ul class="text-[--ui-text-muted] space-y-2">
|
||||
<li>Individual game workers</li>
|
||||
<li>Researchers and students</li>
|
||||
<li>Industry allies and supporters</li>
|
||||
<li>Anyone co-op-curious</li>
|
||||
</ul>
|
||||
<p>
|
||||
Our resources and community space will help you understand
|
||||
cooperative basics, connect with others asking the same
|
||||
questions, and give you a look at real examples from game
|
||||
studios. You don't need to have a studio or project of your
|
||||
own - just join and see what strikes your fancy!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Founder Circle -->
|
||||
<div class="bg-[--ui-bg] rounded-xl p-8">
|
||||
<div class="">
|
||||
<h3 class="text-2xl font-bold text-[--ui-text] mb-2">
|
||||
Founder Circle
|
||||
</h3>
|
||||
<p class="text-lg text-[--ui-text-muted] mb-6">
|
||||
You're actively building or transitioning to a cooperative model
|
||||
|
||||
<div
|
||||
class="prose prose-lg dark:prose-invert max-w-none text-[--ui-text-muted]"
|
||||
>
|
||||
<p>
|
||||
You're way past wondering about "what if" and into "how do we
|
||||
actually do this?" Perhaps you're forming a new cooperative
|
||||
studio from scratch, or converting an existing team to a co-op
|
||||
structure, or working through the messy reality of turning
|
||||
values into sustainable practice.
|
||||
</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<h4
|
||||
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
|
||||
>
|
||||
Where you might be:
|
||||
</h4>
|
||||
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
|
||||
<li>Forming a new cooperative studio</li>
|
||||
<li>Converting an existing studio to a co-op</li>
|
||||
<li>Preparing to apply for the Peer Accelerator</li>
|
||||
<li>Working through governance and structure decisions</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>
|
||||
This is the space for the practical stuff: governance
|
||||
documents you can read and adapt, financial models for
|
||||
cooperative studios, connections with other founders
|
||||
navigating similar challenges.
|
||||
</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<h4
|
||||
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
|
||||
>
|
||||
We'll help you navigate:
|
||||
</h4>
|
||||
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
|
||||
<li>Practical implementation challenges</li>
|
||||
<li>Governance document creation</li>
|
||||
<li>Financial planning for co-ops</li>
|
||||
<li>Peer connections with other founders</li>
|
||||
<li>Balancing ideals with sustainability</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>
|
||||
We have two paths through this circle that we will be
|
||||
launching soon:
|
||||
</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<h4
|
||||
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
|
||||
>
|
||||
Two paths available:
|
||||
</h4>
|
||||
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Peer Accelerator Prep Track:</strong> Structured
|
||||
preparation for the PA program
|
||||
Peer Accelerator Prep Track <em>(coming soon)</em> –
|
||||
Structured preparation if you're planning to apply for the
|
||||
PA program
|
||||
</li>
|
||||
<li>
|
||||
<strong>Indie Track:</strong> Self-paced development for
|
||||
alternative pathways
|
||||
Indie Track <em>(coming soon)</em> – Flexible, self-paced
|
||||
support for teams building at their own pace
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4
|
||||
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
|
||||
>
|
||||
You might be:
|
||||
</h4>
|
||||
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
|
||||
<li>Founding teams</li>
|
||||
<li>Studios in transition</li>
|
||||
<li>PA program applicants</li>
|
||||
<li>Solo founders exploring structures</li>
|
||||
</ul>
|
||||
<p>
|
||||
Join us to figure out how you can balance your values with
|
||||
keeping the lights on - whether you're a full founding team, a
|
||||
solo founder exploring structures, or an existing studio in
|
||||
transition.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Practitioner Circle -->
|
||||
<div class="bg-[--ui-bg] rounded-xl p-8">
|
||||
<div class="">
|
||||
<h3 class="text-2xl font-bold text-[--ui-text] mb-2">
|
||||
Practitioner Circle
|
||||
</h3>
|
||||
<p class="text-lg text-[--ui-text-muted] mb-6">
|
||||
You're operating a cooperative and contributing to the field
|
||||
|
||||
<div
|
||||
class="prose prose-lg dark:prose-invert max-w-none text-[--ui-text-muted]"
|
||||
>
|
||||
<p>
|
||||
You've done it. You're actually running a
|
||||
cooperative/worker-centric studio or you've been through our
|
||||
Peer Accelerator. Now you're figuring out how to sustain it,
|
||||
improve it, and maybe help others learn from what
|
||||
<em>you've</em> learned.
|
||||
</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<h4
|
||||
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
|
||||
>
|
||||
Where you might be:
|
||||
</h4>
|
||||
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
|
||||
<li>Running an established cooperative studio</li>
|
||||
<li>Graduated from the Peer Accelerator</li>
|
||||
<li>Mentoring other cooperatives</li>
|
||||
<li>Advancing cooperative practices in games</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>
|
||||
This circle is for: Peer Accelerator alumni • members of
|
||||
established co-ops • mentors who want to support other
|
||||
cooperatives • researchers studying cooperative models in
|
||||
practice
|
||||
</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<h4
|
||||
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
|
||||
>
|
||||
We'll help you navigate:
|
||||
</h4>
|
||||
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
|
||||
<li>Advanced operational challenges</li>
|
||||
<li>Opportunities to mentor and teach</li>
|
||||
<li>Contributing to best practices</li>
|
||||
<li>Cross-pollination with other co-ops</li>
|
||||
<li>Research and publication opportunities</li>
|
||||
<li>Co-op to co-op collaboration</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4
|
||||
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
|
||||
>
|
||||
You might be:
|
||||
</h4>
|
||||
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
|
||||
<li>Peer Accelerator alumni</li>
|
||||
<li>Established co-op members</li>
|
||||
<li>Industry mentors</li>
|
||||
<li>Cooperative researchers</li>
|
||||
</ul>
|
||||
<p>
|
||||
Here, we create space for practitioners to share what's
|
||||
actually working (and what isn't), support emerging
|
||||
cooperatives, collaborate across studios, and contribute to
|
||||
building a knowledge commons.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="py-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Admin Dashboard</h1>
|
||||
<p class="text-gray-600">Manage Ghost Guild members, events, and community operations</p>
|
||||
<p class="text-gray-600">
|
||||
Manage Ghost Guild members, events, and community operations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -20,9 +22,21 @@
|
|||
{{ stats.totalMembers || 0 }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-.5a4 4 0 110 5.292M4 19.5a4 4 0 010-5.292"></path>
|
||||
<div
|
||||
class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-.5a4 4 0 110 5.292M4 19.5a4 4 0 010-5.292"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -36,9 +50,21 @@
|
|||
{{ stats.activeEvents || 0 }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
<div
|
||||
class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -52,9 +78,21 @@
|
|||
${{ stats.monthlyRevenue || 0 }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"></path>
|
||||
<div
|
||||
class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-purple-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -68,9 +106,21 @@
|
|||
{{ stats.pendingSlackInvites || 0 }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
|
||||
<div
|
||||
class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-orange-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -81,16 +131,31 @@
|
|||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"></path>
|
||||
<div
|
||||
class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2">Add New Member</h3>
|
||||
<p class="text-gray-600 text-sm mb-4">
|
||||
Add a new member to the Ghost Guild community
|
||||
</p>
|
||||
<button @click="navigateTo('/admin/members-working')" class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<button
|
||||
@click="navigateTo('/admin/members-working')"
|
||||
class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Manage Members
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -98,16 +163,31 @@
|
|||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
<div
|
||||
class="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2">Create Event</h3>
|
||||
<p class="text-gray-600 text-sm mb-4">
|
||||
Schedule a new community event or workshop
|
||||
</p>
|
||||
<button @click="navigateTo('/admin/events-working')" class="w-full bg-green-600 text-white py-2 px-4 rounded-lg hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2">
|
||||
<button
|
||||
@click="navigateTo('/admin/events-working')"
|
||||
class="w-full bg-green-600 text-white py-2 px-4 rounded-lg hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
|
||||
>
|
||||
Manage Events
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -115,16 +195,31 @@
|
|||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 bg-purple-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
<div
|
||||
class="w-16 h-16 bg-purple-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-purple-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2">View Analytics</h3>
|
||||
<p class="text-gray-600 text-sm mb-4">
|
||||
Review member engagement and growth metrics
|
||||
</p>
|
||||
<button disabled class="w-full bg-gray-300 text-gray-500 py-2 px-4 rounded-lg cursor-not-allowed">
|
||||
<button
|
||||
disabled
|
||||
class="w-full bg-gray-300 text-gray-500 py-2 px-4 rounded-lg cursor-not-allowed"
|
||||
>
|
||||
Coming Soon
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -137,7 +232,10 @@
|
|||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold">Recent Members</h3>
|
||||
<button @click="navigateTo('/admin/members-working')" class="text-sm text-blue-600 hover:text-blue-900">
|
||||
<button
|
||||
@click="navigateTo('/admin/members-working')"
|
||||
class="text-sm text-primary-600 hover:text-primary-900"
|
||||
>
|
||||
View All
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -145,19 +243,30 @@
|
|||
|
||||
<div class="p-6">
|
||||
<div v-if="pending" class="text-center py-4">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<div
|
||||
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"
|
||||
></div>
|
||||
</div>
|
||||
<div v-else-if="recentMembers.length" class="space-y-3">
|
||||
<div v-for="member in recentMembers" :key="member._id" class="flex items-center justify-between p-3 rounded-lg border border-gray-200">
|
||||
<div
|
||||
v-for="member in recentMembers"
|
||||
:key="member._id"
|
||||
class="flex items-center justify-between p-3 rounded-lg border border-gray-200"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium">{{ member.name }}</p>
|
||||
<p class="text-sm text-gray-600">{{ member.email }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span :class="getCircleBadgeClasses(member.circle)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1">
|
||||
<span
|
||||
:class="getCircleBadgeClasses(member.circle)"
|
||||
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1"
|
||||
>
|
||||
{{ member.circle }}
|
||||
</span>
|
||||
<p class="text-xs text-gray-500">{{ formatDate(member.createdAt) }}</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ formatDate(member.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -171,7 +280,10 @@
|
|||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold">Upcoming Events</h3>
|
||||
<button @click="navigateTo('/admin/events-working')" class="text-sm text-blue-600 hover:text-blue-900">
|
||||
<button
|
||||
@click="navigateTo('/admin/events-working')"
|
||||
class="text-sm text-primary-600 hover:text-primary-900"
|
||||
>
|
||||
View All
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -179,19 +291,32 @@
|
|||
|
||||
<div class="p-6">
|
||||
<div v-if="pending" class="text-center py-4">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<div
|
||||
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"
|
||||
></div>
|
||||
</div>
|
||||
<div v-else-if="upcomingEvents.length" class="space-y-3">
|
||||
<div v-for="event in upcomingEvents" :key="event._id" class="flex items-center justify-between p-3 rounded-lg border border-gray-200">
|
||||
<div
|
||||
v-for="event in upcomingEvents"
|
||||
:key="event._id"
|
||||
class="flex items-center justify-between p-3 rounded-lg border border-gray-200"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium">{{ event.title }}</p>
|
||||
<p class="text-sm text-gray-600">{{ formatDateTime(event.startDate) }}</p>
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ formatDateTime(event.startDate) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span :class="getEventTypeBadgeClasses(event.eventType)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1">
|
||||
<span
|
||||
:class="getEventTypeBadgeClasses(event.eventType)"
|
||||
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1"
|
||||
>
|
||||
{{ event.eventType }}
|
||||
</span>
|
||||
<p class="text-xs text-gray-500">{{ event.location || 'Online' }}</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ event.location || "Online" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -207,44 +332,46 @@
|
|||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
const { data: dashboardData, pending } = await useFetch('/api/admin/dashboard')
|
||||
const { data: dashboardData, pending } = await useFetch("/api/admin/dashboard");
|
||||
|
||||
const stats = computed(() => dashboardData.value?.stats || {})
|
||||
const recentMembers = computed(() => dashboardData.value?.recentMembers || [])
|
||||
const upcomingEvents = computed(() => dashboardData.value?.upcomingEvents || [])
|
||||
const stats = computed(() => dashboardData.value?.stats || {});
|
||||
const recentMembers = computed(() => dashboardData.value?.recentMembers || []);
|
||||
const upcomingEvents = computed(
|
||||
() => dashboardData.value?.upcomingEvents || [],
|
||||
);
|
||||
|
||||
const getCircleBadgeClasses = (circle) => {
|
||||
const classes = {
|
||||
community: 'bg-blue-100 text-blue-800',
|
||||
founder: 'bg-purple-100 text-purple-800',
|
||||
practitioner: 'bg-green-100 text-green-800'
|
||||
}
|
||||
return classes[circle] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
community: "bg-blue-100 text-blue-800",
|
||||
founder: "bg-purple-100 text-purple-800",
|
||||
practitioner: "bg-green-100 text-green-800",
|
||||
};
|
||||
return classes[circle] || "bg-gray-100 text-gray-800";
|
||||
};
|
||||
|
||||
const getEventTypeBadgeClasses = (type) => {
|
||||
const classes = {
|
||||
community: 'bg-blue-100 text-blue-800',
|
||||
workshop: 'bg-green-100 text-green-800',
|
||||
social: 'bg-purple-100 text-purple-800',
|
||||
showcase: 'bg-orange-100 text-orange-800'
|
||||
}
|
||||
return classes[type] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
community: "bg-blue-100 text-blue-800",
|
||||
workshop: "bg-green-100 text-green-800",
|
||||
social: "bg-purple-100 text-purple-800",
|
||||
showcase: "bg-orange-100 text-orange-800",
|
||||
};
|
||||
return classes[type] || "bg-gray-100 text-gray-800";
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
}
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
|
@ -4,7 +4,9 @@
|
|||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="py-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Event Management</h1>
|
||||
<p class="text-gray-600">Create, manage, and monitor Ghost Guild events and workshops</p>
|
||||
<p class="text-gray-600">
|
||||
Create, manage, and monitor Ghost Guild events and workshops
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -13,22 +15,35 @@
|
|||
<!-- Search and Actions -->
|
||||
<div class="mb-6 flex justify-between items-center">
|
||||
<div class="flex gap-4 items-center">
|
||||
<input v-model="searchQuery" placeholder="Search events..." class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
<select v-model="typeFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
placeholder="Search events..."
|
||||
class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<select
|
||||
v-model="typeFilter"
|
||||
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="community">Community</option>
|
||||
<option value="workshop">Workshop</option>
|
||||
<option value="social">Social</option>
|
||||
<option value="showcase">Showcase</option>
|
||||
</select>
|
||||
<select v-model="statusFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<select
|
||||
v-model="statusFilter"
|
||||
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="upcoming">Upcoming</option>
|
||||
<option value="ongoing">Ongoing</option>
|
||||
<option value="past">Past</option>
|
||||
</select>
|
||||
</div>
|
||||
<button @click="showCreateModal = true" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Create Event
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -37,7 +52,9 @@
|
|||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div v-if="pending" class="p-8 text-center">
|
||||
<div class="inline-flex items-center">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"></div>
|
||||
<div
|
||||
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"
|
||||
></div>
|
||||
Loading events...
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -49,22 +66,57 @@
|
|||
<table v-else class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Title</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Start Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Registration</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Title
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Type
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Start Date
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Registration
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="event in filteredEvents" :key="event._id" class="hover:bg-gray-50">
|
||||
<tr
|
||||
v-for="event in filteredEvents"
|
||||
:key="event._id"
|
||||
class="hover:bg-gray-50"
|
||||
>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm font-medium text-gray-900">{{ event.title }}</div>
|
||||
<div class="text-sm text-gray-500">{{ event.description.substring(0, 100) }}...</div>
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
{{ event.title }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ event.description.substring(0, 100) }}...
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="getEventTypeClasses(event.eventType)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
|
||||
<span
|
||||
:class="getEventTypeClasses(event.eventType)"
|
||||
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
|
||||
>
|
||||
{{ event.eventType }}
|
||||
</span>
|
||||
</td>
|
||||
|
|
@ -72,51 +124,94 @@
|
|||
{{ formatDateTime(event.startDate) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="getStatusClasses(event)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
|
||||
<span
|
||||
:class="getStatusClasses(event)"
|
||||
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
|
||||
>
|
||||
{{ getEventStatus(event) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="event.registrationRequired ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
|
||||
{{ event.registrationRequired ? 'Required' : 'Open' }}
|
||||
<span
|
||||
:class="
|
||||
event.registrationRequired
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
"
|
||||
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
|
||||
>
|
||||
{{ event.registrationRequired ? "Required" : "Open" }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
<div class="flex gap-2">
|
||||
<button @click="editEvent(event)" class="text-indigo-600 hover:text-indigo-900">Edit</button>
|
||||
<button @click="duplicateEvent(event)" class="text-blue-600 hover:text-blue-900">Duplicate</button>
|
||||
<button @click="deleteEvent(event)" class="text-red-600 hover:text-red-900">Delete</button>
|
||||
<button
|
||||
@click="editEvent(event)"
|
||||
class="text-primary-600 hover:text-primary-900"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
@click="duplicateEvent(event)"
|
||||
class="text-primary-600 hover:text-primary-900"
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
<button
|
||||
@click="deleteEvent(event)"
|
||||
class="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="!pending && !error && filteredEvents.length === 0" class="p-8 text-center text-gray-500">
|
||||
<div
|
||||
v-if="!pending && !error && filteredEvents.length === 0"
|
||||
class="p-8 text-center text-gray-500"
|
||||
>
|
||||
No events found matching your criteria
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Event Modal -->
|
||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto">
|
||||
<div
|
||||
v-if="showCreateModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto"
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 my-8">
|
||||
<div class="px-6 py-4 border-b">
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ editingEvent ? 'Edit Event' : 'Create New Event' }}
|
||||
{{ editingEvent ? "Edit Event" : "Create New Event" }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="saveEvent" class="p-6 space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Event Title</label>
|
||||
<input v-model="eventForm.title" placeholder="Enter event title" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Event Title</label
|
||||
>
|
||||
<input
|
||||
v-model="eventForm.title"
|
||||
placeholder="Enter event title"
|
||||
required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Event Type</label>
|
||||
<select v-model="eventForm.eventType" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Event Type</label
|
||||
>
|
||||
<select
|
||||
v-model="eventForm.eventType"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="community">Community Meetup</option>
|
||||
<option value="workshop">Workshop</option>
|
||||
<option value="social">Social Event</option>
|
||||
|
|
@ -125,58 +220,131 @@
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Location</label>
|
||||
<input v-model="eventForm.location" placeholder="Event location" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Location</label
|
||||
>
|
||||
<input
|
||||
v-model="eventForm.location"
|
||||
placeholder="Event location"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Start Date & Time</label>
|
||||
<input v-model="eventForm.startDate" type="datetime-local" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Start Date & Time</label
|
||||
>
|
||||
<input
|
||||
v-model="eventForm.startDate"
|
||||
type="datetime-local"
|
||||
required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">End Date & Time</label>
|
||||
<input v-model="eventForm.endDate" type="datetime-local" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>End Date & Time</label
|
||||
>
|
||||
<input
|
||||
v-model="eventForm.endDate"
|
||||
type="datetime-local"
|
||||
required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Max Attendees</label>
|
||||
<input v-model="eventForm.maxAttendees" type="number" placeholder="Optional" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Max Attendees</label
|
||||
>
|
||||
<input
|
||||
v-model="eventForm.maxAttendees"
|
||||
type="number"
|
||||
placeholder="Optional"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Registration Deadline</label>
|
||||
<input v-model="eventForm.registrationDeadline" type="datetime-local" placeholder="Optional" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Registration Deadline</label
|
||||
>
|
||||
<input
|
||||
v-model="eventForm.registrationDeadline"
|
||||
type="datetime-local"
|
||||
placeholder="Optional"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea v-model="eventForm.description" placeholder="Event description" required rows="3" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"></textarea>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Description</label
|
||||
>
|
||||
<textarea
|
||||
v-model="eventForm.description"
|
||||
placeholder="Event description"
|
||||
required
|
||||
rows="3"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Additional Content</label>
|
||||
<textarea v-model="eventForm.content" placeholder="Detailed event information, agenda, etc." rows="4" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"></textarea>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Additional Content</label
|
||||
>
|
||||
<textarea
|
||||
v-model="eventForm.content"
|
||||
placeholder="Detailed event information, agenda, etc."
|
||||
rows="4"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-6">
|
||||
<label class="flex items-center">
|
||||
<input v-model="eventForm.isOnline" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
|
||||
<input
|
||||
v-model="eventForm.isOnline"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700">Online Event</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input v-model="eventForm.registrationRequired" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
|
||||
<span class="ml-2 text-sm text-gray-700">Registration Required</span>
|
||||
<input
|
||||
v-model="eventForm.registrationRequired"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-700"
|
||||
>Registration Required</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" @click="cancelEdit" class="px-4 py-2 text-gray-600 hover:text-gray-900">
|
||||
<button
|
||||
type="button"
|
||||
@click="cancelEdit"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" :disabled="creating" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{{ creating ? 'Saving...' : (editingEvent ? 'Update Event' : 'Create Event') }}
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="creating"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{
|
||||
creating
|
||||
? "Saving..."
|
||||
: editingEvent
|
||||
? "Update Event"
|
||||
: "Create Event"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -187,175 +355,185 @@
|
|||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
const { data: events, pending, error, refresh } = await useFetch("/api/admin/events")
|
||||
const {
|
||||
data: events,
|
||||
pending,
|
||||
error,
|
||||
refresh,
|
||||
} = await useFetch("/api/admin/events");
|
||||
|
||||
const searchQuery = ref('')
|
||||
const typeFilter = ref('')
|
||||
const statusFilter = ref('')
|
||||
const showCreateModal = ref(false)
|
||||
const creating = ref(false)
|
||||
const editingEvent = ref(null)
|
||||
const searchQuery = ref("");
|
||||
const typeFilter = ref("");
|
||||
const statusFilter = ref("");
|
||||
const showCreateModal = ref(false);
|
||||
const creating = ref(false);
|
||||
const editingEvent = ref(null);
|
||||
|
||||
const eventForm = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
content: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
eventType: 'community',
|
||||
location: '',
|
||||
title: "",
|
||||
description: "",
|
||||
content: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
eventType: "community",
|
||||
location: "",
|
||||
isOnline: false,
|
||||
maxAttendees: '',
|
||||
maxAttendees: "",
|
||||
registrationRequired: false,
|
||||
registrationDeadline: ''
|
||||
})
|
||||
registrationDeadline: "",
|
||||
});
|
||||
|
||||
const filteredEvents = computed(() => {
|
||||
if (!events.value) return []
|
||||
if (!events.value) return [];
|
||||
|
||||
return events.value.filter(event => {
|
||||
const matchesSearch = !searchQuery.value ||
|
||||
return events.value.filter((event) => {
|
||||
const matchesSearch =
|
||||
!searchQuery.value ||
|
||||
event.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
event.description.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
event.description.toLowerCase().includes(searchQuery.value.toLowerCase());
|
||||
|
||||
const matchesType = !typeFilter.value || event.eventType === typeFilter.value
|
||||
const matchesType =
|
||||
!typeFilter.value || event.eventType === typeFilter.value;
|
||||
|
||||
const eventStatus = getEventStatus(event)
|
||||
const matchesStatus = !statusFilter.value || eventStatus.toLowerCase() === statusFilter.value
|
||||
const eventStatus = getEventStatus(event);
|
||||
const matchesStatus =
|
||||
!statusFilter.value || eventStatus.toLowerCase() === statusFilter.value;
|
||||
|
||||
return matchesSearch && matchesType && matchesStatus
|
||||
})
|
||||
})
|
||||
return matchesSearch && matchesType && matchesStatus;
|
||||
});
|
||||
});
|
||||
|
||||
const getEventTypeClasses = (type) => {
|
||||
const classes = {
|
||||
community: 'bg-blue-100 text-blue-800',
|
||||
workshop: 'bg-green-100 text-green-800',
|
||||
social: 'bg-purple-100 text-purple-800',
|
||||
showcase: 'bg-orange-100 text-orange-800'
|
||||
}
|
||||
return classes[type] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
community: "bg-blue-100 text-blue-800",
|
||||
workshop: "bg-green-100 text-green-800",
|
||||
social: "bg-purple-100 text-purple-800",
|
||||
showcase: "bg-orange-100 text-orange-800",
|
||||
};
|
||||
return classes[type] || "bg-gray-100 text-gray-800";
|
||||
};
|
||||
|
||||
const getEventStatus = (event) => {
|
||||
const now = new Date()
|
||||
const startDate = new Date(event.startDate)
|
||||
const endDate = new Date(event.endDate)
|
||||
const now = new Date();
|
||||
const startDate = new Date(event.startDate);
|
||||
const endDate = new Date(event.endDate);
|
||||
|
||||
if (now < startDate) return 'Upcoming'
|
||||
if (now >= startDate && now <= endDate) return 'Ongoing'
|
||||
return 'Past'
|
||||
}
|
||||
if (now < startDate) return "Upcoming";
|
||||
if (now >= startDate && now <= endDate) return "Ongoing";
|
||||
return "Past";
|
||||
};
|
||||
|
||||
const getStatusClasses = (event) => {
|
||||
const status = getEventStatus(event)
|
||||
const status = getEventStatus(event);
|
||||
const classes = {
|
||||
'Upcoming': 'bg-blue-100 text-blue-800',
|
||||
'Ongoing': 'bg-green-100 text-green-800',
|
||||
'Past': 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
return classes[status] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
Upcoming: "bg-blue-100 text-blue-800",
|
||||
Ongoing: "bg-green-100 text-green-800",
|
||||
Past: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
return classes[status] || "bg-gray-100 text-gray-800";
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
return new Date(dateString).toLocaleString()
|
||||
}
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const saveEvent = async () => {
|
||||
creating.value = true
|
||||
creating.value = true;
|
||||
try {
|
||||
if (editingEvent.value) {
|
||||
await $fetch(`/api/admin/events/${editingEvent.value._id}`, {
|
||||
method: 'PUT',
|
||||
body: eventForm
|
||||
})
|
||||
method: "PUT",
|
||||
body: eventForm,
|
||||
});
|
||||
} else {
|
||||
await $fetch('/api/admin/events', {
|
||||
method: 'POST',
|
||||
body: eventForm
|
||||
})
|
||||
await $fetch("/api/admin/events", {
|
||||
method: "POST",
|
||||
body: eventForm,
|
||||
});
|
||||
}
|
||||
|
||||
cancelEdit()
|
||||
await refresh()
|
||||
alert('Event saved successfully!')
|
||||
cancelEdit();
|
||||
await refresh();
|
||||
alert("Event saved successfully!");
|
||||
} catch (error) {
|
||||
console.error('Failed to save event:', error)
|
||||
alert('Failed to save event')
|
||||
console.error("Failed to save event:", error);
|
||||
alert("Failed to save event");
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
creating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const editEvent = (event) => {
|
||||
editingEvent.value = event
|
||||
editingEvent.value = event;
|
||||
Object.assign(eventForm, {
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
content: event.content || '',
|
||||
content: event.content || "",
|
||||
startDate: new Date(event.startDate).toISOString().slice(0, 16),
|
||||
endDate: new Date(event.endDate).toISOString().slice(0, 16),
|
||||
eventType: event.eventType,
|
||||
location: event.location || '',
|
||||
location: event.location || "",
|
||||
isOnline: event.isOnline,
|
||||
maxAttendees: event.maxAttendees || '',
|
||||
maxAttendees: event.maxAttendees || "",
|
||||
registrationRequired: event.registrationRequired,
|
||||
registrationDeadline: event.registrationDeadline ? new Date(event.registrationDeadline).toISOString().slice(0, 16) : ''
|
||||
})
|
||||
showCreateModal.value = true
|
||||
}
|
||||
registrationDeadline: event.registrationDeadline
|
||||
? new Date(event.registrationDeadline).toISOString().slice(0, 16)
|
||||
: "",
|
||||
});
|
||||
showCreateModal.value = true;
|
||||
};
|
||||
|
||||
const duplicateEvent = (event) => {
|
||||
editingEvent.value = null
|
||||
editingEvent.value = null;
|
||||
Object.assign(eventForm, {
|
||||
title: `${event.title} (Copy)`,
|
||||
description: event.description,
|
||||
content: event.content || '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
content: event.content || "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
eventType: event.eventType,
|
||||
location: event.location || '',
|
||||
location: event.location || "",
|
||||
isOnline: event.isOnline,
|
||||
maxAttendees: event.maxAttendees || '',
|
||||
maxAttendees: event.maxAttendees || "",
|
||||
registrationRequired: event.registrationRequired,
|
||||
registrationDeadline: ''
|
||||
})
|
||||
showCreateModal.value = true
|
||||
}
|
||||
registrationDeadline: "",
|
||||
});
|
||||
showCreateModal.value = true;
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
showCreateModal.value = false
|
||||
editingEvent.value = null
|
||||
showCreateModal.value = false;
|
||||
editingEvent.value = null;
|
||||
Object.assign(eventForm, {
|
||||
title: '',
|
||||
description: '',
|
||||
content: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
eventType: 'community',
|
||||
location: '',
|
||||
title: "",
|
||||
description: "",
|
||||
content: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
eventType: "community",
|
||||
location: "",
|
||||
isOnline: false,
|
||||
maxAttendees: '',
|
||||
maxAttendees: "",
|
||||
registrationRequired: false,
|
||||
registrationDeadline: ''
|
||||
})
|
||||
}
|
||||
registrationDeadline: "",
|
||||
});
|
||||
};
|
||||
|
||||
const deleteEvent = async (event) => {
|
||||
if (confirm(`Are you sure you want to delete "${event.title}"?`)) {
|
||||
try {
|
||||
await $fetch(`/api/admin/events/${event._id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
await refresh()
|
||||
alert('Event deleted successfully!')
|
||||
method: "DELETE",
|
||||
});
|
||||
await refresh();
|
||||
alert("Event deleted successfully!");
|
||||
} catch (error) {
|
||||
console.error('Failed to delete event:', error)
|
||||
alert('Failed to delete event')
|
||||
}
|
||||
console.error("Failed to delete event:", error);
|
||||
alert("Failed to delete event");
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -4,7 +4,9 @@
|
|||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="py-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Event Management</h1>
|
||||
<p class="text-gray-600">Create, manage, and monitor Ghost Guild events and workshops</p>
|
||||
<p class="text-gray-600">
|
||||
Create, manage, and monitor Ghost Guild events and workshops
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -13,27 +15,43 @@
|
|||
<!-- Search and Actions -->
|
||||
<div class="mb-6 flex justify-between items-center">
|
||||
<div class="flex gap-4 items-center">
|
||||
<input v-model="searchQuery" placeholder="Search events..." class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
<select v-model="typeFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
placeholder="Search events..."
|
||||
class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<select
|
||||
v-model="typeFilter"
|
||||
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="community">Community</option>
|
||||
<option value="workshop">Workshop</option>
|
||||
<option value="social">Social</option>
|
||||
<option value="showcase">Showcase</option>
|
||||
</select>
|
||||
<select v-model="statusFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<select
|
||||
v-model="statusFilter"
|
||||
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="upcoming">Upcoming</option>
|
||||
<option value="ongoing">Ongoing</option>
|
||||
<option value="past">Past</option>
|
||||
</select>
|
||||
<select v-model="seriesFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<select
|
||||
v-model="seriesFilter"
|
||||
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Events</option>
|
||||
<option value="series-only">Series Events Only</option>
|
||||
<option value="standalone-only">Standalone Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<NuxtLink to="/admin/events/create" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 inline-flex items-center">
|
||||
<NuxtLink
|
||||
to="/admin/events/create"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 inline-flex items-center"
|
||||
>
|
||||
<Icon name="heroicons:plus" class="w-4 h-4 mr-2" />
|
||||
Create Event
|
||||
</NuxtLink>
|
||||
|
|
@ -43,7 +61,9 @@
|
|||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div v-if="pending" class="p-8 text-center">
|
||||
<div class="inline-flex items-center">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"></div>
|
||||
<div
|
||||
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"
|
||||
></div>
|
||||
Loading events...
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -55,20 +75,53 @@
|
|||
<table v-else class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Title</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Registration</th>
|
||||
<th class="px-6 py-4 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
<th
|
||||
class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Title
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Type
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Date
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Registration
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="event in filteredEvents" :key="event._id" class="hover:bg-gray-50">
|
||||
<tr
|
||||
v-for="event in filteredEvents"
|
||||
:key="event._id"
|
||||
class="hover:bg-gray-50"
|
||||
>
|
||||
<!-- Title Column -->
|
||||
<td class="px-6 py-6">
|
||||
<div class="flex items-start space-x-3">
|
||||
<div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg overflow-hidden">
|
||||
<div
|
||||
v-if="
|
||||
event.featureImage?.url && !event.featureImage?.publicId
|
||||
"
|
||||
class="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg overflow-hidden"
|
||||
>
|
||||
<img
|
||||
:src="event.featureImage.url"
|
||||
:alt="event.title"
|
||||
|
|
@ -76,30 +129,63 @@
|
|||
@error="handleImageError($event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<Icon name="heroicons:calendar-days" class="w-6 h-6 text-gray-400" />
|
||||
<div
|
||||
v-else
|
||||
class="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center"
|
||||
>
|
||||
<Icon
|
||||
name="heroicons:calendar-days"
|
||||
class="w-6 h-6 text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-semibold text-gray-900 mb-1">{{ event.title }}</div>
|
||||
<div class="text-sm text-gray-500 line-clamp-2">{{ event.description.substring(0, 100) }}...</div>
|
||||
<div class="text-sm font-semibold text-gray-900 mb-1">
|
||||
{{ event.title }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 line-clamp-2">
|
||||
{{ event.description.substring(0, 100) }}...
|
||||
</div>
|
||||
<div v-if="event.series?.isSeriesEvent" class="mt-2 mb-2">
|
||||
<div class="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-700 text-xs font-medium rounded-full">
|
||||
<div class="w-4 h-4 bg-purple-200 text-purple-700 rounded-full flex items-center justify-center text-xs font-bold">
|
||||
<div
|
||||
class="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-700 text-xs font-medium rounded-full"
|
||||
>
|
||||
<div
|
||||
class="w-4 h-4 bg-purple-200 text-purple-700 rounded-full flex items-center justify-center text-xs font-bold"
|
||||
>
|
||||
{{ event.series.position }}
|
||||
</div>
|
||||
{{ event.series.title }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4 mt-2">
|
||||
<div v-if="event.membersOnly" class="flex items-center text-xs text-purple-600">
|
||||
<Icon name="heroicons:lock-closed" class="w-3 h-3 mr-1" />
|
||||
<div
|
||||
v-if="event.membersOnly"
|
||||
class="flex items-center text-xs text-purple-600"
|
||||
>
|
||||
<Icon
|
||||
name="heroicons:lock-closed"
|
||||
class="w-3 h-3 mr-1"
|
||||
/>
|
||||
Members Only
|
||||
</div>
|
||||
<div v-if="event.targetCircles && event.targetCircles.length > 0" class="flex items-center space-x-1">
|
||||
<Icon name="heroicons:user-group" class="w-3 h-3 text-gray-400" />
|
||||
<span class="text-xs text-gray-500">{{ event.targetCircles.join(', ') }}</span>
|
||||
<div
|
||||
v-if="
|
||||
event.targetCircles && event.targetCircles.length > 0
|
||||
"
|
||||
class="flex items-center space-x-1"
|
||||
>
|
||||
<Icon
|
||||
name="heroicons:user-group"
|
||||
class="w-3 h-3 text-gray-400"
|
||||
/>
|
||||
<span class="text-xs text-gray-500">{{
|
||||
event.targetCircles.join(", ")
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="!event.isVisible" class="flex items-center text-xs text-gray-500">
|
||||
<div
|
||||
v-if="!event.isVisible"
|
||||
class="flex items-center text-xs text-gray-500"
|
||||
>
|
||||
<Icon name="heroicons:eye-slash" class="w-3 h-3 mr-1" />
|
||||
Hidden
|
||||
</div>
|
||||
|
|
@ -110,7 +196,10 @@
|
|||
|
||||
<!-- Type Column -->
|
||||
<td class="px-4 py-6 whitespace-nowrap">
|
||||
<span :class="getEventTypeClasses(event.eventType)" class="inline-flex px-3 py-1 text-xs font-semibold rounded-full capitalize">
|
||||
<span
|
||||
:class="getEventTypeClasses(event.eventType)"
|
||||
class="inline-flex px-3 py-1 text-xs font-semibold rounded-full capitalize"
|
||||
>
|
||||
{{ event.eventType }}
|
||||
</span>
|
||||
</td>
|
||||
|
|
@ -118,18 +207,28 @@
|
|||
<!-- Date Column -->
|
||||
<td class="px-4 py-6 whitespace-nowrap text-sm text-gray-600">
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">{{ formatDate(event.startDate) }}</div>
|
||||
<div class="text-xs text-gray-500">{{ formatTime(event.startDate) }}</div>
|
||||
<div class="font-medium">
|
||||
{{ formatDate(event.startDate) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ formatTime(event.startDate) }}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Status Column -->
|
||||
<td class="px-4 py-6 whitespace-nowrap">
|
||||
<div class="space-y-2">
|
||||
<span :class="getStatusClasses(event)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
|
||||
<span
|
||||
:class="getStatusClasses(event)"
|
||||
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
|
||||
>
|
||||
{{ getEventStatus(event) }}
|
||||
</span>
|
||||
<div v-if="event.isCancelled" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800">
|
||||
<div
|
||||
v-if="event.isCancelled"
|
||||
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800"
|
||||
>
|
||||
Cancelled
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -138,10 +237,16 @@
|
|||
<!-- Registration Column -->
|
||||
<td class="px-4 py-6 whitespace-nowrap">
|
||||
<div class="space-y-2">
|
||||
<div v-if="event.registrationRequired" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
|
||||
<div
|
||||
v-if="event.registrationRequired"
|
||||
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800"
|
||||
>
|
||||
Required
|
||||
</div>
|
||||
<div v-else class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||
<div
|
||||
v-else
|
||||
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-800"
|
||||
>
|
||||
Optional
|
||||
</div>
|
||||
<div v-if="event.maxAttendees" class="text-xs text-gray-500">
|
||||
|
|
@ -162,14 +267,14 @@
|
|||
</NuxtLink>
|
||||
<button
|
||||
@click="editEvent(event)"
|
||||
class="p-2 text-indigo-500 hover:text-indigo-700 hover:bg-indigo-50 rounded-full transition-colors"
|
||||
class="p-2 text-primary-500 hover:text-primary-700 hover:bg-primary-50 rounded-full transition-colors"
|
||||
title="Edit Event"
|
||||
>
|
||||
<Icon name="heroicons:pencil-square" class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="duplicateEvent(event)"
|
||||
class="p-2 text-blue-500 hover:text-blue-700 hover:bg-blue-50 rounded-full transition-colors"
|
||||
class="p-2 text-primary-500 hover:text-primary-700 hover:bg-primary-50 rounded-full transition-colors"
|
||||
title="Duplicate Event"
|
||||
>
|
||||
<Icon name="heroicons:document-duplicate" class="w-4 h-4" />
|
||||
|
|
@ -187,155 +292,165 @@
|
|||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="!pending && !error && filteredEvents.length === 0" class="p-8 text-center text-gray-500">
|
||||
<div
|
||||
v-if="!pending && !error && filteredEvents.length === 0"
|
||||
class="p-8 text-center text-gray-500"
|
||||
>
|
||||
No events found matching your criteria
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
const { data: events, pending, error, refresh } = await useFetch("/api/admin/events")
|
||||
const {
|
||||
data: events,
|
||||
pending,
|
||||
error,
|
||||
refresh,
|
||||
} = await useFetch("/api/admin/events");
|
||||
|
||||
const searchQuery = ref('')
|
||||
const typeFilter = ref('')
|
||||
const statusFilter = ref('')
|
||||
const seriesFilter = ref('')
|
||||
const searchQuery = ref("");
|
||||
const typeFilter = ref("");
|
||||
const statusFilter = ref("");
|
||||
const seriesFilter = ref("");
|
||||
|
||||
const filteredEvents = computed(() => {
|
||||
if (!events.value) return []
|
||||
if (!events.value) return [];
|
||||
|
||||
return events.value.filter(event => {
|
||||
const matchesSearch = !searchQuery.value ||
|
||||
return events.value.filter((event) => {
|
||||
const matchesSearch =
|
||||
!searchQuery.value ||
|
||||
event.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
event.description.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
event.description.toLowerCase().includes(searchQuery.value.toLowerCase());
|
||||
|
||||
const matchesType = !typeFilter.value || event.eventType === typeFilter.value
|
||||
const matchesType =
|
||||
!typeFilter.value || event.eventType === typeFilter.value;
|
||||
|
||||
const eventStatus = getEventStatus(event)
|
||||
const matchesStatus = !statusFilter.value || eventStatus.toLowerCase() === statusFilter.value
|
||||
const eventStatus = getEventStatus(event);
|
||||
const matchesStatus =
|
||||
!statusFilter.value || eventStatus.toLowerCase() === statusFilter.value;
|
||||
|
||||
const matchesSeries = !seriesFilter.value ||
|
||||
(seriesFilter.value === 'series-only' && event.series?.isSeriesEvent) ||
|
||||
(seriesFilter.value === 'standalone-only' && !event.series?.isSeriesEvent)
|
||||
const matchesSeries =
|
||||
!seriesFilter.value ||
|
||||
(seriesFilter.value === "series-only" && event.series?.isSeriesEvent) ||
|
||||
(seriesFilter.value === "standalone-only" &&
|
||||
!event.series?.isSeriesEvent);
|
||||
|
||||
return matchesSearch && matchesType && matchesStatus && matchesSeries
|
||||
})
|
||||
})
|
||||
return matchesSearch && matchesType && matchesStatus && matchesSeries;
|
||||
});
|
||||
});
|
||||
|
||||
const getEventTypeClasses = (type) => {
|
||||
const classes = {
|
||||
community: 'bg-blue-100 text-blue-800',
|
||||
workshop: 'bg-green-100 text-green-800',
|
||||
social: 'bg-purple-100 text-purple-800',
|
||||
showcase: 'bg-orange-100 text-orange-800'
|
||||
}
|
||||
return classes[type] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
community: "bg-blue-100 text-blue-800",
|
||||
workshop: "bg-green-100 text-green-800",
|
||||
social: "bg-purple-100 text-purple-800",
|
||||
showcase: "bg-orange-100 text-orange-800",
|
||||
};
|
||||
return classes[type] || "bg-gray-100 text-gray-800";
|
||||
};
|
||||
|
||||
const getEventStatus = (event) => {
|
||||
const now = new Date()
|
||||
const startDate = new Date(event.startDate)
|
||||
const endDate = new Date(event.endDate)
|
||||
const now = new Date();
|
||||
const startDate = new Date(event.startDate);
|
||||
const endDate = new Date(event.endDate);
|
||||
|
||||
if (now < startDate) return 'Upcoming'
|
||||
if (now >= startDate && now <= endDate) return 'Ongoing'
|
||||
return 'Past'
|
||||
}
|
||||
if (now < startDate) return "Upcoming";
|
||||
if (now >= startDate && now <= endDate) return "Ongoing";
|
||||
return "Past";
|
||||
};
|
||||
|
||||
const getStatusClasses = (event) => {
|
||||
const status = getEventStatus(event)
|
||||
const status = getEventStatus(event);
|
||||
const classes = {
|
||||
'Upcoming': 'bg-blue-100 text-blue-800',
|
||||
'Ongoing': 'bg-green-100 text-green-800',
|
||||
'Past': 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
return classes[status] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
Upcoming: "bg-blue-100 text-blue-800",
|
||||
Ongoing: "bg-green-100 text-green-800",
|
||||
Past: "bg-gray-100 text-gray-800",
|
||||
};
|
||||
return classes[status] || "bg-gray-100 text-gray-800";
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
return new Date(dateString).toLocaleString()
|
||||
}
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
return new Date(dateString).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (dateString) => {
|
||||
return new Date(dateString).toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})
|
||||
}
|
||||
return new Date(dateString).toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
};
|
||||
|
||||
// Get optimized Cloudinary image URL
|
||||
const getOptimizedImageUrl = (publicId, transformations) => {
|
||||
if (!publicId) return ''
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}`
|
||||
}
|
||||
|
||||
if (!publicId) return "";
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}`;
|
||||
};
|
||||
|
||||
const duplicateEvent = (event) => {
|
||||
// Navigate to create page with duplicate query parameter
|
||||
const duplicateData = {
|
||||
title: `${event.title} (Copy)`,
|
||||
description: event.description,
|
||||
content: event.content || '',
|
||||
content: event.content || "",
|
||||
featureImage: event.featureImage || null,
|
||||
eventType: event.eventType,
|
||||
location: event.location || '',
|
||||
location: event.location || "",
|
||||
isOnline: event.isOnline,
|
||||
isVisible: true,
|
||||
isCancelled: false,
|
||||
cancellationMessage: '',
|
||||
cancellationMessage: "",
|
||||
targetCircles: event.targetCircles || [],
|
||||
maxAttendees: event.maxAttendees || '',
|
||||
registrationRequired: event.registrationRequired
|
||||
}
|
||||
maxAttendees: event.maxAttendees || "",
|
||||
registrationRequired: event.registrationRequired,
|
||||
};
|
||||
|
||||
// Store duplicate data in session storage for the create page to use
|
||||
sessionStorage.setItem('duplicateEventData', JSON.stringify(duplicateData))
|
||||
navigateTo('/admin/events/create?duplicate=true')
|
||||
}
|
||||
sessionStorage.setItem("duplicateEventData", JSON.stringify(duplicateData));
|
||||
navigateTo("/admin/events/create?duplicate=true");
|
||||
};
|
||||
|
||||
const deleteEvent = async (event) => {
|
||||
if (confirm(`Are you sure you want to delete "${event.title}"?`)) {
|
||||
try {
|
||||
await $fetch(`/api/admin/events/${String(event._id)}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
await refresh()
|
||||
alert('Event deleted successfully!')
|
||||
method: "DELETE",
|
||||
});
|
||||
await refresh();
|
||||
alert("Event deleted successfully!");
|
||||
} catch (error) {
|
||||
console.error('Failed to delete event:', error)
|
||||
alert('Failed to delete event')
|
||||
}
|
||||
console.error("Failed to delete event:", error);
|
||||
alert("Failed to delete event");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageError = (event) => {
|
||||
const img = event.target
|
||||
const container = img?.parentElement
|
||||
const img = event.target;
|
||||
const container = img?.parentElement;
|
||||
if (container) {
|
||||
container.style.display = 'none'
|
||||
}
|
||||
container.style.display = "none";
|
||||
}
|
||||
};
|
||||
|
||||
const editEvent = (event) => {
|
||||
navigateTo(`/admin/events/create?edit=${String(event._id)}`)
|
||||
}
|
||||
navigateTo(`/admin/events/create?edit=${String(event._id)}`);
|
||||
};
|
||||
</script>
|
||||
|
|
@ -4,7 +4,9 @@
|
|||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="py-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Admin Dashboard</h1>
|
||||
<p class="text-gray-600">Manage Ghost Guild members, events, and community operations</p>
|
||||
<p class="text-gray-600">
|
||||
Manage Ghost Guild members, events, and community operations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -20,9 +22,21 @@
|
|||
{{ stats.totalMembers || 0 }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-.5a4 4 0 110 5.292M4 19.5a4 4 0 010-5.292"></path>
|
||||
<div
|
||||
class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-.5a4 4 0 110 5.292M4 19.5a4 4 0 010-5.292"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -36,9 +50,21 @@
|
|||
{{ stats.activeEvents || 0 }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
<div
|
||||
class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -52,9 +78,21 @@
|
|||
${{ stats.monthlyRevenue || 0 }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"></path>
|
||||
<div
|
||||
class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-purple-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -68,9 +106,21 @@
|
|||
{{ stats.pendingSlackInvites || 0 }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
|
||||
<div
|
||||
class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-orange-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -81,16 +131,31 @@
|
|||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"></path>
|
||||
<div
|
||||
class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2">Add New Member</h3>
|
||||
<p class="text-gray-600 text-sm mb-4">
|
||||
Add a new member to the Ghost Guild community
|
||||
</p>
|
||||
<button @click="navigateTo('/admin/members')" class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<button
|
||||
@click="navigateTo('/admin/members')"
|
||||
class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Manage Members
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -98,16 +163,31 @@
|
|||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
<div
|
||||
class="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2">Create Event</h3>
|
||||
<p class="text-gray-600 text-sm mb-4">
|
||||
Schedule a new community event or workshop
|
||||
</p>
|
||||
<button @click="navigateTo('/admin/events')" class="w-full bg-green-600 text-white py-2 px-4 rounded-lg hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2">
|
||||
<button
|
||||
@click="navigateTo('/admin/events')"
|
||||
class="w-full bg-green-600 text-white py-2 px-4 rounded-lg hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
|
||||
>
|
||||
Manage Events
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -115,16 +195,31 @@
|
|||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 bg-purple-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
<div
|
||||
class="w-16 h-16 bg-purple-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-purple-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2">View Analytics</h3>
|
||||
<p class="text-gray-600 text-sm mb-4">
|
||||
Review member engagement and growth metrics
|
||||
</p>
|
||||
<button disabled class="w-full bg-gray-300 text-gray-500 py-2 px-4 rounded-lg cursor-not-allowed">
|
||||
<button
|
||||
disabled
|
||||
class="w-full bg-gray-300 text-gray-500 py-2 px-4 rounded-lg cursor-not-allowed"
|
||||
>
|
||||
Coming Soon
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -137,7 +232,10 @@
|
|||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold">Recent Members</h3>
|
||||
<button @click="navigateTo('/admin/members')" class="text-sm text-blue-600 hover:text-blue-900">
|
||||
<button
|
||||
@click="navigateTo('/admin/members')"
|
||||
class="text-sm text-primary-600 hover:text-primary-900"
|
||||
>
|
||||
View All
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -145,19 +243,30 @@
|
|||
|
||||
<div class="p-6">
|
||||
<div v-if="pending" class="text-center py-4">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<div
|
||||
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"
|
||||
></div>
|
||||
</div>
|
||||
<div v-else-if="recentMembers.length" class="space-y-3">
|
||||
<div v-for="member in recentMembers" :key="member._id" class="flex items-center justify-between p-3 rounded-lg border border-gray-200">
|
||||
<div
|
||||
v-for="member in recentMembers"
|
||||
:key="member._id"
|
||||
class="flex items-center justify-between p-3 rounded-lg border border-gray-200"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium">{{ member.name }}</p>
|
||||
<p class="text-sm text-gray-600">{{ member.email }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span :class="getCircleBadgeClasses(member.circle)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1">
|
||||
<span
|
||||
:class="getCircleBadgeClasses(member.circle)"
|
||||
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1"
|
||||
>
|
||||
{{ member.circle }}
|
||||
</span>
|
||||
<p class="text-xs text-gray-500">{{ formatDate(member.createdAt) }}</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ formatDate(member.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -171,7 +280,10 @@
|
|||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold">Upcoming Events</h3>
|
||||
<button @click="navigateTo('/admin/events')" class="text-sm text-blue-600 hover:text-blue-900">
|
||||
<button
|
||||
@click="navigateTo('/admin/events')"
|
||||
class="text-sm text-primary-600 hover:text-primary-900"
|
||||
>
|
||||
View All
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -179,19 +291,32 @@
|
|||
|
||||
<div class="p-6">
|
||||
<div v-if="pending" class="text-center py-4">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<div
|
||||
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"
|
||||
></div>
|
||||
</div>
|
||||
<div v-else-if="upcomingEvents.length" class="space-y-3">
|
||||
<div v-for="event in upcomingEvents" :key="event._id" class="flex items-center justify-between p-3 rounded-lg border border-gray-200">
|
||||
<div
|
||||
v-for="event in upcomingEvents"
|
||||
:key="event._id"
|
||||
class="flex items-center justify-between p-3 rounded-lg border border-gray-200"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium">{{ event.title }}</p>
|
||||
<p class="text-sm text-gray-600">{{ formatDateTime(event.startDate) }}</p>
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ formatDateTime(event.startDate) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span :class="getEventTypeBadgeClasses(event.eventType)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1">
|
||||
<span
|
||||
:class="getEventTypeBadgeClasses(event.eventType)"
|
||||
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1"
|
||||
>
|
||||
{{ event.eventType }}
|
||||
</span>
|
||||
<p class="text-xs text-gray-500">{{ event.location || 'Online' }}</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ event.location || "Online" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -207,44 +332,46 @@
|
|||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
const { data: dashboardData, pending } = await useFetch('/api/admin/dashboard')
|
||||
const { data: dashboardData, pending } = await useFetch("/api/admin/dashboard");
|
||||
|
||||
const stats = computed(() => dashboardData.value?.stats || {})
|
||||
const recentMembers = computed(() => dashboardData.value?.recentMembers || [])
|
||||
const upcomingEvents = computed(() => dashboardData.value?.upcomingEvents || [])
|
||||
const stats = computed(() => dashboardData.value?.stats || {});
|
||||
const recentMembers = computed(() => dashboardData.value?.recentMembers || []);
|
||||
const upcomingEvents = computed(
|
||||
() => dashboardData.value?.upcomingEvents || [],
|
||||
);
|
||||
|
||||
const getCircleBadgeClasses = (circle) => {
|
||||
const classes = {
|
||||
community: 'bg-blue-100 text-blue-800',
|
||||
founder: 'bg-purple-100 text-purple-800',
|
||||
practitioner: 'bg-green-100 text-green-800'
|
||||
}
|
||||
return classes[circle] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
community: "bg-blue-100 text-blue-800",
|
||||
founder: "bg-purple-100 text-purple-800",
|
||||
practitioner: "bg-green-100 text-green-800",
|
||||
};
|
||||
return classes[circle] || "bg-gray-100 text-gray-800";
|
||||
};
|
||||
|
||||
const getEventTypeBadgeClasses = (type) => {
|
||||
const classes = {
|
||||
community: 'bg-blue-100 text-blue-800',
|
||||
workshop: 'bg-green-100 text-green-800',
|
||||
social: 'bg-purple-100 text-purple-800',
|
||||
showcase: 'bg-orange-100 text-orange-800'
|
||||
}
|
||||
return classes[type] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
community: "bg-blue-100 text-blue-800",
|
||||
workshop: "bg-green-100 text-green-800",
|
||||
social: "bg-purple-100 text-purple-800",
|
||||
showcase: "bg-orange-100 text-orange-800",
|
||||
};
|
||||
return classes[type] || "bg-gray-100 text-gray-800";
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
}
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
|
@ -4,7 +4,9 @@
|
|||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="py-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Member Management</h1>
|
||||
<p class="text-gray-600">Manage Ghost Guild members, their contributions, and access levels</p>
|
||||
<p class="text-gray-600">
|
||||
Manage Ghost Guild members, their contributions, and access levels
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -13,15 +15,25 @@
|
|||
<!-- Search and Actions -->
|
||||
<div class="mb-6 flex justify-between items-center">
|
||||
<div class="flex gap-4 items-center">
|
||||
<input v-model="searchQuery" placeholder="Search members..." class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
<select v-model="circleFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
placeholder="Search members..."
|
||||
class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<select
|
||||
v-model="circleFilter"
|
||||
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Circles</option>
|
||||
<option value="community">Community</option>
|
||||
<option value="founder">Founder</option>
|
||||
<option value="practitioner">Practitioner</option>
|
||||
</select>
|
||||
</div>
|
||||
<button @click="showCreateModal = true" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Add Member
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -30,7 +42,9 @@
|
|||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div v-if="pending" class="p-8 text-center">
|
||||
<div class="inline-flex items-center">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"></div>
|
||||
<div
|
||||
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"
|
||||
></div>
|
||||
Loading members...
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -42,36 +56,82 @@
|
|||
<table v-else class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Circle</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Contribution</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Slack Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Joined</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Name
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Email
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Circle
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Contribution
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Slack Status
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Joined
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="member in filteredMembers" :key="member._id" class="hover:bg-gray-50">
|
||||
<tr
|
||||
v-for="member in filteredMembers"
|
||||
:key="member._id"
|
||||
class="hover:bg-gray-50"
|
||||
>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">{{ member.name }}</div>
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
{{ member.name }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-600">{{ member.email }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="getCircleClasses(member.circle)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
|
||||
<span
|
||||
:class="getCircleClasses(member.circle)"
|
||||
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
|
||||
>
|
||||
{{ member.circle }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
|
||||
<span
|
||||
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800"
|
||||
>
|
||||
${{ member.contributionTier }}/month
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="member.slackInvited ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
|
||||
{{ member.slackInvited ? 'Invited' : 'Pending' }}
|
||||
<span
|
||||
:class="
|
||||
member.slackInvited
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
"
|
||||
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
|
||||
>
|
||||
{{ member.slackInvited ? "Invited" : "Pending" }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
|
|
@ -79,22 +139,38 @@
|
|||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
<div class="flex gap-2">
|
||||
<button @click="sendSlackInvite(member)" class="text-blue-600 hover:text-blue-900">Slack Invite</button>
|
||||
<button @click="editMember(member)" class="text-indigo-600 hover:text-indigo-900">Edit</button>
|
||||
<button
|
||||
@click="sendSlackInvite(member)"
|
||||
class="text-primary-600 hover:text-primary-900"
|
||||
>
|
||||
Slack Invite
|
||||
</button>
|
||||
<button
|
||||
@click="editMember(member)"
|
||||
class="text-primary-600 hover:text-primary-900"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="!pending && !error && filteredMembers.length === 0" class="p-8 text-center text-gray-500">
|
||||
<div
|
||||
v-if="!pending && !error && filteredMembers.length === 0"
|
||||
class="p-8 text-center text-gray-500"
|
||||
>
|
||||
No members found matching your criteria
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Member Modal -->
|
||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div
|
||||
v-if="showCreateModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
<div class="px-6 py-4 border-b">
|
||||
<h3 class="text-lg font-semibold">Add New Member</h3>
|
||||
|
|
@ -102,18 +178,38 @@
|
|||
|
||||
<form @submit.prevent="createMember" class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input v-model="newMember.name" placeholder="Full name" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Name</label
|
||||
>
|
||||
<input
|
||||
v-model="newMember.name"
|
||||
placeholder="Full name"
|
||||
required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input v-model="newMember.email" type="email" placeholder="email@example.com" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Email</label
|
||||
>
|
||||
<input
|
||||
v-model="newMember.email"
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Circle</label>
|
||||
<select v-model="newMember.circle" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Circle</label
|
||||
>
|
||||
<select
|
||||
v-model="newMember.circle"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="community">Community</option>
|
||||
<option value="founder">Founder</option>
|
||||
<option value="practitioner">Practitioner</option>
|
||||
|
|
@ -121,8 +217,13 @@
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Contribution Tier</label>
|
||||
<select v-model="newMember.contributionTier" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Contribution Tier</label
|
||||
>
|
||||
<select
|
||||
v-model="newMember.contributionTier"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="0">$0/month</option>
|
||||
<option value="5">$5/month</option>
|
||||
<option value="15">$15/month</option>
|
||||
|
|
@ -132,11 +233,19 @@
|
|||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" @click="showCreateModal = false" class="px-4 py-2 text-gray-600 hover:text-gray-900">
|
||||
<button
|
||||
type="button"
|
||||
@click="showCreateModal = false"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" :disabled="creating" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{{ creating ? 'Creating...' : 'Create Member' }}
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="creating"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ creating ? "Creating..." : "Create Member" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -147,83 +256,90 @@
|
|||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
const { data: members, pending, error, refresh } = await useFetch("/api/admin/members")
|
||||
const {
|
||||
data: members,
|
||||
pending,
|
||||
error,
|
||||
refresh,
|
||||
} = await useFetch("/api/admin/members");
|
||||
|
||||
const searchQuery = ref('')
|
||||
const circleFilter = ref('')
|
||||
const showCreateModal = ref(false)
|
||||
const creating = ref(false)
|
||||
const searchQuery = ref("");
|
||||
const circleFilter = ref("");
|
||||
const showCreateModal = ref(false);
|
||||
const creating = ref(false);
|
||||
|
||||
const newMember = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
circle: 'community',
|
||||
contributionTier: '0'
|
||||
})
|
||||
name: "",
|
||||
email: "",
|
||||
circle: "community",
|
||||
contributionTier: "0",
|
||||
});
|
||||
|
||||
const filteredMembers = computed(() => {
|
||||
if (!members.value) return []
|
||||
if (!members.value) return [];
|
||||
|
||||
return members.value.filter(member => {
|
||||
const matchesSearch = !searchQuery.value ||
|
||||
return members.value.filter((member) => {
|
||||
const matchesSearch =
|
||||
!searchQuery.value ||
|
||||
member.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
member.email.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
member.email.toLowerCase().includes(searchQuery.value.toLowerCase());
|
||||
|
||||
const matchesCircle = !circleFilter.value || member.circle === circleFilter.value
|
||||
const matchesCircle =
|
||||
!circleFilter.value || member.circle === circleFilter.value;
|
||||
|
||||
return matchesSearch && matchesCircle
|
||||
})
|
||||
})
|
||||
return matchesSearch && matchesCircle;
|
||||
});
|
||||
});
|
||||
|
||||
const getCircleClasses = (circle) => {
|
||||
const classes = {
|
||||
community: 'bg-blue-100 text-blue-800',
|
||||
founder: 'bg-purple-100 text-purple-800',
|
||||
practitioner: 'bg-green-100 text-green-800'
|
||||
}
|
||||
return classes[circle] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
community: "bg-blue-100 text-blue-800",
|
||||
founder: "bg-purple-100 text-purple-800",
|
||||
practitioner: "bg-green-100 text-green-800",
|
||||
};
|
||||
return classes[circle] || "bg-gray-100 text-gray-800";
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
}
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
const createMember = async () => {
|
||||
creating.value = true
|
||||
creating.value = true;
|
||||
try {
|
||||
await $fetch('/api/admin/members', {
|
||||
method: 'POST',
|
||||
body: newMember
|
||||
})
|
||||
await $fetch("/api/admin/members", {
|
||||
method: "POST",
|
||||
body: newMember,
|
||||
});
|
||||
|
||||
showCreateModal.value = false
|
||||
showCreateModal.value = false;
|
||||
Object.assign(newMember, {
|
||||
name: '',
|
||||
email: '',
|
||||
circle: 'community',
|
||||
contributionTier: '0'
|
||||
})
|
||||
name: "",
|
||||
email: "",
|
||||
circle: "community",
|
||||
contributionTier: "0",
|
||||
});
|
||||
|
||||
await refresh()
|
||||
alert('Member created successfully!')
|
||||
await refresh();
|
||||
alert("Member created successfully!");
|
||||
} catch (error) {
|
||||
console.error('Failed to create member:', error)
|
||||
alert('Failed to create member')
|
||||
console.error("Failed to create member:", error);
|
||||
alert("Failed to create member");
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
creating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const sendSlackInvite = (member) => {
|
||||
alert(`Slack invite functionality would send invite to ${member.email}`)
|
||||
console.log('Send Slack invite to:', member.email)
|
||||
}
|
||||
alert(`Slack invite functionality would send invite to ${member.email}`);
|
||||
console.log("Send Slack invite to:", member.email);
|
||||
};
|
||||
|
||||
const editMember = (member) => {
|
||||
alert(`Edit functionality would open editor for ${member.name}`)
|
||||
console.log('Edit member:', member._id)
|
||||
}
|
||||
alert(`Edit functionality would open editor for ${member.name}`);
|
||||
console.log("Edit member:", member._id);
|
||||
};
|
||||
</script>
|
||||
|
|
@ -4,7 +4,9 @@
|
|||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="py-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Member Management</h1>
|
||||
<p class="text-gray-600">Manage Ghost Guild members, their contributions, and access levels</p>
|
||||
<p class="text-gray-600">
|
||||
Manage Ghost Guild members, their contributions, and access levels
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -13,15 +15,25 @@
|
|||
<!-- Search and Actions -->
|
||||
<div class="mb-6 flex justify-between items-center">
|
||||
<div class="flex gap-4 items-center">
|
||||
<input v-model="searchQuery" placeholder="Search members..." class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
<select v-model="circleFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
placeholder="Search members..."
|
||||
class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<select
|
||||
v-model="circleFilter"
|
||||
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Circles</option>
|
||||
<option value="community">Community</option>
|
||||
<option value="founder">Founder</option>
|
||||
<option value="practitioner">Practitioner</option>
|
||||
</select>
|
||||
</div>
|
||||
<button @click="showCreateModal = true" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Add Member
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -30,7 +42,9 @@
|
|||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div v-if="pending" class="p-8 text-center">
|
||||
<div class="inline-flex items-center">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"></div>
|
||||
<div
|
||||
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"
|
||||
></div>
|
||||
Loading members...
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -42,36 +56,82 @@
|
|||
<table v-else class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Circle</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Contribution</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Slack Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Joined</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Name
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Email
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Circle
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Contribution
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Slack Status
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Joined
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="member in filteredMembers" :key="member._id" class="hover:bg-gray-50">
|
||||
<tr
|
||||
v-for="member in filteredMembers"
|
||||
:key="member._id"
|
||||
class="hover:bg-gray-50"
|
||||
>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">{{ member.name }}</div>
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
{{ member.name }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-600">{{ member.email }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="getCircleClasses(member.circle)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
|
||||
<span
|
||||
:class="getCircleClasses(member.circle)"
|
||||
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
|
||||
>
|
||||
{{ member.circle }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
|
||||
<span
|
||||
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800"
|
||||
>
|
||||
${{ member.contributionTier }}/month
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span :class="member.slackInvited ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
|
||||
{{ member.slackInvited ? 'Invited' : 'Pending' }}
|
||||
<span
|
||||
:class="
|
||||
member.slackInvited
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
"
|
||||
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
|
||||
>
|
||||
{{ member.slackInvited ? "Invited" : "Pending" }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
|
|
@ -79,22 +139,38 @@
|
|||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||
<div class="flex gap-2">
|
||||
<button @click="sendSlackInvite(member)" class="text-blue-600 hover:text-blue-900">Slack Invite</button>
|
||||
<button @click="editMember(member)" class="text-indigo-600 hover:text-indigo-900">Edit</button>
|
||||
<button
|
||||
@click="sendSlackInvite(member)"
|
||||
class="text-primary-600 hover:text-primary-900"
|
||||
>
|
||||
Slack Invite
|
||||
</button>
|
||||
<button
|
||||
@click="editMember(member)"
|
||||
class="text-primary-600 hover:text-primary-900"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="!pending && !error && filteredMembers.length === 0" class="p-8 text-center text-gray-500">
|
||||
<div
|
||||
v-if="!pending && !error && filteredMembers.length === 0"
|
||||
class="p-8 text-center text-gray-500"
|
||||
>
|
||||
No members found matching your criteria
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Member Modal -->
|
||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div
|
||||
v-if="showCreateModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
<div class="px-6 py-4 border-b">
|
||||
<h3 class="text-lg font-semibold">Add New Member</h3>
|
||||
|
|
@ -102,18 +178,38 @@
|
|||
|
||||
<form @submit.prevent="createMember" class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input v-model="newMember.name" placeholder="Full name" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Name</label
|
||||
>
|
||||
<input
|
||||
v-model="newMember.name"
|
||||
placeholder="Full name"
|
||||
required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input v-model="newMember.email" type="email" placeholder="email@example.com" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Email</label
|
||||
>
|
||||
<input
|
||||
v-model="newMember.email"
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Circle</label>
|
||||
<select v-model="newMember.circle" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Circle</label
|
||||
>
|
||||
<select
|
||||
v-model="newMember.circle"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="community">Community</option>
|
||||
<option value="founder">Founder</option>
|
||||
<option value="practitioner">Practitioner</option>
|
||||
|
|
@ -121,8 +217,13 @@
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Contribution Tier</label>
|
||||
<select v-model="newMember.contributionTier" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Contribution Tier</label
|
||||
>
|
||||
<select
|
||||
v-model="newMember.contributionTier"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="0">$0/month</option>
|
||||
<option value="5">$5/month</option>
|
||||
<option value="15">$15/month</option>
|
||||
|
|
@ -132,11 +233,19 @@
|
|||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" @click="showCreateModal = false" class="px-4 py-2 text-gray-600 hover:text-gray-900">
|
||||
<button
|
||||
type="button"
|
||||
@click="showCreateModal = false"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" :disabled="creating" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{{ creating ? 'Creating...' : 'Create Member' }}
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="creating"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ creating ? "Creating..." : "Create Member" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -147,83 +256,90 @@
|
|||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
const { data: members, pending, error, refresh } = await useFetch("/api/admin/members")
|
||||
const {
|
||||
data: members,
|
||||
pending,
|
||||
error,
|
||||
refresh,
|
||||
} = await useFetch("/api/admin/members");
|
||||
|
||||
const searchQuery = ref('')
|
||||
const circleFilter = ref('')
|
||||
const showCreateModal = ref(false)
|
||||
const creating = ref(false)
|
||||
const searchQuery = ref("");
|
||||
const circleFilter = ref("");
|
||||
const showCreateModal = ref(false);
|
||||
const creating = ref(false);
|
||||
|
||||
const newMember = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
circle: 'community',
|
||||
contributionTier: '0'
|
||||
})
|
||||
name: "",
|
||||
email: "",
|
||||
circle: "community",
|
||||
contributionTier: "0",
|
||||
});
|
||||
|
||||
const filteredMembers = computed(() => {
|
||||
if (!members.value) return []
|
||||
if (!members.value) return [];
|
||||
|
||||
return members.value.filter(member => {
|
||||
const matchesSearch = !searchQuery.value ||
|
||||
return members.value.filter((member) => {
|
||||
const matchesSearch =
|
||||
!searchQuery.value ||
|
||||
member.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
member.email.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
member.email.toLowerCase().includes(searchQuery.value.toLowerCase());
|
||||
|
||||
const matchesCircle = !circleFilter.value || member.circle === circleFilter.value
|
||||
const matchesCircle =
|
||||
!circleFilter.value || member.circle === circleFilter.value;
|
||||
|
||||
return matchesSearch && matchesCircle
|
||||
})
|
||||
})
|
||||
return matchesSearch && matchesCircle;
|
||||
});
|
||||
});
|
||||
|
||||
const getCircleClasses = (circle) => {
|
||||
const classes = {
|
||||
community: 'bg-blue-100 text-blue-800',
|
||||
founder: 'bg-purple-100 text-purple-800',
|
||||
practitioner: 'bg-green-100 text-green-800'
|
||||
}
|
||||
return classes[circle] || 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
community: "bg-blue-100 text-blue-800",
|
||||
founder: "bg-purple-100 text-purple-800",
|
||||
practitioner: "bg-green-100 text-green-800",
|
||||
};
|
||||
return classes[circle] || "bg-gray-100 text-gray-800";
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
}
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
const createMember = async () => {
|
||||
creating.value = true
|
||||
creating.value = true;
|
||||
try {
|
||||
await $fetch('/api/admin/members', {
|
||||
method: 'POST',
|
||||
body: newMember
|
||||
})
|
||||
await $fetch("/api/admin/members", {
|
||||
method: "POST",
|
||||
body: newMember,
|
||||
});
|
||||
|
||||
showCreateModal.value = false
|
||||
showCreateModal.value = false;
|
||||
Object.assign(newMember, {
|
||||
name: '',
|
||||
email: '',
|
||||
circle: 'community',
|
||||
contributionTier: '0'
|
||||
})
|
||||
name: "",
|
||||
email: "",
|
||||
circle: "community",
|
||||
contributionTier: "0",
|
||||
});
|
||||
|
||||
await refresh()
|
||||
alert('Member created successfully!')
|
||||
await refresh();
|
||||
alert("Member created successfully!");
|
||||
} catch (error) {
|
||||
console.error('Failed to create member:', error)
|
||||
alert('Failed to create member')
|
||||
console.error("Failed to create member:", error);
|
||||
alert("Failed to create member");
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
creating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const sendSlackInvite = (member) => {
|
||||
alert(`Slack invite functionality would send invite to ${member.email}`)
|
||||
console.log('Send Slack invite to:', member.email)
|
||||
}
|
||||
alert(`Slack invite functionality would send invite to ${member.email}`);
|
||||
console.log("Send Slack invite to:", member.email);
|
||||
};
|
||||
|
||||
const editMember = (member) => {
|
||||
alert(`Edit functionality would open editor for ${member.name}`)
|
||||
console.log('Edit member:', member._id)
|
||||
}
|
||||
alert(`Edit functionality would open editor for ${member.name}`);
|
||||
console.log("Edit member:", member._id);
|
||||
};
|
||||
</script>
|
||||
|
|
@ -4,7 +4,9 @@
|
|||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="py-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Series Management</h1>
|
||||
<p class="text-gray-600">Manage event series and their relationships</p>
|
||||
<p class="text-gray-600">
|
||||
Manage event series and their relationships
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -16,34 +18,51 @@
|
|||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 bg-purple-100 rounded-full">
|
||||
<Icon name="heroicons:squares-2x2" class="w-6 h-6 text-purple-600" />
|
||||
<Icon
|
||||
name="heroicons:squares-2x2"
|
||||
class="w-6 h-6 text-purple-600"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm text-gray-500">Active Series</p>
|
||||
<p class="text-2xl font-semibold text-gray-900">{{ activeSeries.length }}</p>
|
||||
<p class="text-2xl font-semibold text-gray-900">
|
||||
{{ activeSeries.length }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 bg-blue-100 rounded-full">
|
||||
<Icon name="heroicons:calendar-days" class="w-6 h-6 text-blue-600" />
|
||||
<Icon
|
||||
name="heroicons:calendar-days"
|
||||
class="w-6 h-6 text-blue-600"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm text-gray-500">Total Series Events</p>
|
||||
<p class="text-2xl font-semibold text-gray-900">{{ totalSeriesEvents }}</p>
|
||||
<p class="text-2xl font-semibold text-gray-900">
|
||||
{{ totalSeriesEvents }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 bg-green-100 rounded-full">
|
||||
<Icon name="heroicons:chart-bar" class="w-6 h-6 text-green-600" />
|
||||
<Icon
|
||||
name="heroicons:chart-bar"
|
||||
class="w-6 h-6 text-green-600"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm text-gray-500">Avg Events/Series</p>
|
||||
<p class="text-2xl font-semibold text-gray-900">
|
||||
{{ activeSeries.length > 0 ? Math.round(totalSeriesEvents / activeSeries.length) : 0 }}
|
||||
{{
|
||||
activeSeries.length > 0
|
||||
? Math.round(totalSeriesEvents / activeSeries.length)
|
||||
: 0
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -89,7 +108,9 @@
|
|||
|
||||
<!-- Series List -->
|
||||
<div v-if="pending" class="text-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500 mx-auto mb-4"></div>
|
||||
<div
|
||||
class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500 mx-auto mb-4"
|
||||
></div>
|
||||
<p class="text-gray-600">Loading series...</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -103,24 +124,32 @@
|
|||
<div class="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div :class="[
|
||||
<div
|
||||
:class="[
|
||||
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
|
||||
getSeriesTypeBadgeClass(series.type)
|
||||
]">
|
||||
getSeriesTypeBadgeClass(series.type),
|
||||
]"
|
||||
>
|
||||
{{ formatSeriesType(series.type) }}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ series.title }}</h3>
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
{{ series.title }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">{{ series.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span :class="[
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
|
||||
series.status === 'active' ? 'bg-green-100 text-green-700' :
|
||||
series.status === 'upcoming' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
]">
|
||||
series.status === 'active'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: series.status === 'upcoming'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-gray-100 text-gray-700',
|
||||
]"
|
||||
>
|
||||
{{ series.status }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
|
|
@ -139,19 +168,27 @@
|
|||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-8 h-8 bg-purple-100 text-purple-600 rounded-full flex items-center justify-center text-sm font-semibold">
|
||||
{{ event.series?.position || '?' }}
|
||||
<div
|
||||
class="w-8 h-8 bg-purple-100 text-purple-600 rounded-full flex items-center justify-center text-sm font-semibold"
|
||||
>
|
||||
{{ event.series?.position || "?" }}
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900">{{ event.title }}</h4>
|
||||
<p class="text-xs text-gray-500">{{ formatEventDate(event.startDate) }}</p>
|
||||
<h4 class="text-sm font-medium text-gray-900">
|
||||
{{ event.title }}
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ formatEventDate(event.startDate) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="[
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
|
||||
getEventStatusClass(event)
|
||||
]">
|
||||
getEventStatusClass(event),
|
||||
]"
|
||||
>
|
||||
{{ getEventStatus(event) }}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
|
|
@ -164,7 +201,7 @@
|
|||
</NuxtLink>
|
||||
<button
|
||||
@click="editEvent(event)"
|
||||
class="p-1 text-gray-400 hover:text-purple-600 rounded"
|
||||
class="p-1 text-gray-400 hover:text-primary rounded"
|
||||
title="Edit Event"
|
||||
>
|
||||
<Icon name="heroicons:pencil-square" class="w-4 h-4" />
|
||||
|
|
@ -189,15 +226,21 @@
|
|||
{{ formatDateRange(series.startDate, series.endDate) }}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="editSeries(series)"
|
||||
class="text-sm text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
Edit Series
|
||||
</button>
|
||||
<button
|
||||
@click="addEventToSeries(series)"
|
||||
class="text-sm text-purple-600 hover:text-purple-700 font-medium"
|
||||
class="text-sm text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
Add Event
|
||||
</button>
|
||||
<button
|
||||
@click="duplicateSeries(series)"
|
||||
class="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||
class="text-sm text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
Duplicate Series
|
||||
</button>
|
||||
|
|
@ -214,19 +257,32 @@
|
|||
</div>
|
||||
|
||||
<div v-else class="text-center py-12 bg-white rounded-lg shadow">
|
||||
<Icon name="heroicons:squares-2x2" class="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||
<Icon
|
||||
name="heroicons:squares-2x2"
|
||||
class="w-12 h-12 text-gray-400 mx-auto mb-3"
|
||||
/>
|
||||
<p class="text-gray-600">No event series found</p>
|
||||
<p class="text-sm text-gray-500 mt-2">Create events and group them into series to get started</p>
|
||||
<p class="text-sm text-gray-500 mt-2">
|
||||
Create events and group them into series to get started
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Operations Modal -->
|
||||
<div v-if="showBulkModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<!-- Edit Series Modal -->
|
||||
<div
|
||||
v-if="editingSeriesId"
|
||||
class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||
>
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Bulk Series Operations</h3>
|
||||
<button @click="showBulkModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Edit Series</h3>
|
||||
<button
|
||||
@click="cancelEditSeries"
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<Icon name="heroicons:x-mark" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -234,17 +290,119 @@
|
|||
|
||||
<div class="p-6 space-y-6">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">Series Management Tools</h4>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Series Title</label
|
||||
>
|
||||
<input
|
||||
v-model="editingSeriesData.title"
|
||||
type="text"
|
||||
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="e.g., Co-op Game Dev Workshop Series"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Description</label
|
||||
>
|
||||
<textarea
|
||||
v-model="editingSeriesData.description"
|
||||
rows="3"
|
||||
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="Brief description of this series"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Series Type</label
|
||||
>
|
||||
<select
|
||||
v-model="editingSeriesData.type"
|
||||
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="workshop_series">Workshop Series</option>
|
||||
<option value="recurring_meetup">Recurring Meetup</option>
|
||||
<option value="multi_day">Multi-Day Event</option>
|
||||
<option value="course">Course</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Total Events (optional)</label
|
||||
>
|
||||
<input
|
||||
v-model.number="editingSeriesData.totalEvents"
|
||||
type="number"
|
||||
min="1"
|
||||
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="Leave empty for ongoing series"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
|
||||
<button
|
||||
@click="cancelEditSeries"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="saveSeriesEdit"
|
||||
class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Operations Modal -->
|
||||
<div
|
||||
v-if="showBulkModal"
|
||||
class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||
>
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">
|
||||
Bulk Series Operations
|
||||
</h3>
|
||||
<button
|
||||
@click="showBulkModal = false"
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<Icon name="heroicons:x-mark" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-6">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">
|
||||
Series Management Tools
|
||||
</h4>
|
||||
<div class="space-y-3">
|
||||
<button
|
||||
@click="reorderAllSeries"
|
||||
class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Icon name="heroicons:arrows-up-down" class="w-5 h-5 text-gray-400 mr-3" />
|
||||
<Icon
|
||||
name="heroicons:arrows-up-down"
|
||||
class="w-5 h-5 text-gray-400 mr-3"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Auto-Reorder Series</p>
|
||||
<p class="text-xs text-gray-500">Fix position numbers based on event dates</p>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
Auto-Reorder Series
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
Fix position numbers based on event dates
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -254,10 +412,17 @@
|
|||
class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Icon name="heroicons:check-circle" class="w-5 h-5 text-gray-400 mr-3" />
|
||||
<Icon
|
||||
name="heroicons:check-circle"
|
||||
class="w-5 h-5 text-gray-400 mr-3"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Validate Series Data</p>
|
||||
<p class="text-xs text-gray-500">Check for consistency issues</p>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
Validate Series Data
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
Check for consistency issues
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -267,10 +432,17 @@
|
|||
class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Icon name="heroicons:document-arrow-down" class="w-5 h-5 text-gray-400 mr-3" />
|
||||
<Icon
|
||||
name="heroicons:document-arrow-down"
|
||||
class="w-5 h-5 text-gray-400 mr-3"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Export Series Data</p>
|
||||
<p class="text-xs text-gray-500">Download series information as JSON</p>
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
Export Series Data
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
Download series information as JSON
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -293,136 +465,152 @@
|
|||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
layout: "admin",
|
||||
});
|
||||
|
||||
const showBulkModal = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const statusFilter = ref('')
|
||||
const showBulkModal = ref(false);
|
||||
const searchQuery = ref("");
|
||||
const statusFilter = ref("");
|
||||
const editingSeriesId = ref(null);
|
||||
const editingSeriesData = ref({
|
||||
title: "",
|
||||
description: "",
|
||||
type: "workshop_series",
|
||||
totalEvents: null,
|
||||
});
|
||||
|
||||
// Fetch series data
|
||||
const { data: seriesData, pending, refresh } = await useFetch('/api/admin/series')
|
||||
const {
|
||||
data: seriesData,
|
||||
pending,
|
||||
refresh,
|
||||
} = await useFetch("/api/admin/series");
|
||||
|
||||
// Computed properties
|
||||
const activeSeries = computed(() => {
|
||||
if (!seriesData.value) return []
|
||||
return seriesData.value
|
||||
})
|
||||
if (!seriesData.value) return [];
|
||||
return seriesData.value;
|
||||
});
|
||||
|
||||
const totalSeriesEvents = computed(() => {
|
||||
return activeSeries.value.reduce((sum, series) => sum + (series.eventCount || 0), 0)
|
||||
})
|
||||
return activeSeries.value.reduce(
|
||||
(sum, series) => sum + (series.eventCount || 0),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
const filteredSeries = computed(() => {
|
||||
if (!activeSeries.value) return []
|
||||
if (!activeSeries.value) return [];
|
||||
|
||||
return activeSeries.value.filter(series => {
|
||||
const matchesSearch = !searchQuery.value ||
|
||||
return activeSeries.value.filter((series) => {
|
||||
const matchesSearch =
|
||||
!searchQuery.value ||
|
||||
series.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
series.description.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
series.description
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.value.toLowerCase());
|
||||
|
||||
const matchesStatus = !statusFilter.value || series.status === statusFilter.value
|
||||
const matchesStatus =
|
||||
!statusFilter.value || series.status === statusFilter.value;
|
||||
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
})
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
const formatSeriesType = (type) => {
|
||||
const types = {
|
||||
'workshop_series': 'Workshop Series',
|
||||
'recurring_meetup': 'Recurring Meetup',
|
||||
'multi_day': 'Multi-Day Event',
|
||||
'course': 'Course',
|
||||
'tournament': 'Tournament'
|
||||
}
|
||||
return types[type] || type
|
||||
}
|
||||
workshop_series: "Workshop Series",
|
||||
recurring_meetup: "Recurring Meetup",
|
||||
multi_day: "Multi-Day Event",
|
||||
course: "Course",
|
||||
};
|
||||
return types[type] || type;
|
||||
};
|
||||
|
||||
const getSeriesTypeBadgeClass = (type) => {
|
||||
const classes = {
|
||||
'workshop_series': 'bg-emerald-100 text-emerald-700',
|
||||
'recurring_meetup': 'bg-blue-100 text-blue-700',
|
||||
'multi_day': 'bg-purple-100 text-purple-700',
|
||||
'course': 'bg-amber-100 text-amber-700',
|
||||
'tournament': 'bg-red-100 text-red-700'
|
||||
}
|
||||
return classes[type] || 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
workshop_series: "bg-emerald-100 text-emerald-700",
|
||||
recurring_meetup: "bg-blue-100 text-blue-700",
|
||||
multi_day: "bg-purple-100 text-purple-700",
|
||||
course: "bg-amber-100 text-amber-700",
|
||||
};
|
||||
return classes[type] || "bg-gray-100 text-gray-700";
|
||||
};
|
||||
|
||||
const formatEventDate = (date) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatDateRange = (startDate, endDate) => {
|
||||
if (!startDate || !endDate) return 'No dates'
|
||||
if (!startDate || !endDate) return "No dates";
|
||||
|
||||
const start = new Date(startDate)
|
||||
const end = new Date(endDate)
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
return `${formatter.format(start)} - ${formatter.format(end)}`
|
||||
}
|
||||
return `${formatter.format(start)} - ${formatter.format(end)}`;
|
||||
};
|
||||
|
||||
const getEventStatus = (event) => {
|
||||
const now = new Date()
|
||||
const startDate = new Date(event.startDate)
|
||||
const endDate = new Date(event.endDate)
|
||||
const now = new Date();
|
||||
const startDate = new Date(event.startDate);
|
||||
const endDate = new Date(event.endDate);
|
||||
|
||||
if (now < startDate) return 'Upcoming'
|
||||
if (now >= startDate && now <= endDate) return 'Ongoing'
|
||||
return 'Completed'
|
||||
}
|
||||
if (now < startDate) return "Upcoming";
|
||||
if (now >= startDate && now <= endDate) return "Ongoing";
|
||||
return "Completed";
|
||||
};
|
||||
|
||||
const getEventStatusClass = (event) => {
|
||||
const status = getEventStatus(event)
|
||||
const status = getEventStatus(event);
|
||||
const classes = {
|
||||
'Upcoming': 'bg-blue-100 text-blue-700',
|
||||
'Ongoing': 'bg-green-100 text-green-700',
|
||||
'Completed': 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
return classes[status] || 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
Upcoming: "bg-blue-100 text-blue-700",
|
||||
Ongoing: "bg-green-100 text-green-700",
|
||||
Completed: "bg-gray-100 text-gray-700",
|
||||
};
|
||||
return classes[status] || "bg-gray-100 text-gray-700";
|
||||
};
|
||||
|
||||
// Actions
|
||||
const editEvent = (event) => {
|
||||
navigateTo(`/admin/events/create?edit=${event.id}`)
|
||||
}
|
||||
navigateTo(`/admin/events/create?edit=${event.id}`);
|
||||
};
|
||||
|
||||
const removeFromSeries = async (event) => {
|
||||
if (!confirm(`Remove "${event.title}" from its series?`)) return
|
||||
if (!confirm(`Remove "${event.title}" from its series?`)) return;
|
||||
|
||||
try {
|
||||
await $fetch(`/api/admin/events/${event.id}`, {
|
||||
method: 'PUT',
|
||||
method: "PUT",
|
||||
body: {
|
||||
...event,
|
||||
series: {
|
||||
isSeriesEvent: false,
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'workshop_series',
|
||||
id: "",
|
||||
title: "",
|
||||
description: "",
|
||||
type: "workshop_series",
|
||||
position: 1,
|
||||
totalEvents: null
|
||||
}
|
||||
}
|
||||
})
|
||||
totalEvents: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await refresh()
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
console.error('Failed to remove event from series:', error)
|
||||
alert('Failed to remove event from series')
|
||||
}
|
||||
console.error("Failed to remove event from series:", error);
|
||||
alert("Failed to remove event from series");
|
||||
}
|
||||
};
|
||||
|
||||
const addEventToSeries = (series) => {
|
||||
// Navigate to create page with series pre-filled
|
||||
|
|
@ -434,71 +622,121 @@ const addEventToSeries = (series) => {
|
|||
description: series.description,
|
||||
type: series.type,
|
||||
position: (series.eventCount || 0) + 1,
|
||||
totalEvents: series.totalEvents
|
||||
}
|
||||
}
|
||||
totalEvents: series.totalEvents,
|
||||
},
|
||||
};
|
||||
|
||||
sessionStorage.setItem('seriesEventData', JSON.stringify(seriesData))
|
||||
navigateTo('/admin/events/create?series=true')
|
||||
}
|
||||
sessionStorage.setItem("seriesEventData", JSON.stringify(seriesData));
|
||||
navigateTo("/admin/events/create?series=true");
|
||||
};
|
||||
|
||||
const duplicateSeries = (series) => {
|
||||
// TODO: Implement series duplication
|
||||
alert('Series duplication coming soon!')
|
||||
alert("Series duplication coming soon!");
|
||||
};
|
||||
|
||||
const editSeries = (series) => {
|
||||
editingSeriesId.value = series.id;
|
||||
editingSeriesData.value = {
|
||||
title: series.title,
|
||||
description: series.description,
|
||||
type: series.type,
|
||||
totalEvents: series.totalEvents,
|
||||
};
|
||||
};
|
||||
|
||||
const cancelEditSeries = () => {
|
||||
editingSeriesId.value = null;
|
||||
editingSeriesData.value = {
|
||||
title: "",
|
||||
description: "",
|
||||
type: "workshop_series",
|
||||
totalEvents: null,
|
||||
};
|
||||
};
|
||||
|
||||
const saveSeriesEdit = async () => {
|
||||
if (!editingSeriesData.value.title) {
|
||||
alert("Series title is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Update the series record
|
||||
await $fetch("/api/admin/series", {
|
||||
method: "PUT",
|
||||
body: {
|
||||
id: editingSeriesId.value,
|
||||
...editingSeriesData.value,
|
||||
},
|
||||
});
|
||||
|
||||
await refresh();
|
||||
cancelEditSeries();
|
||||
alert("Series updated successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to update series:", error);
|
||||
alert("Failed to update series");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSeries = async (series) => {
|
||||
if (!confirm(`Delete the entire "${series.title}" series? This will remove the series relationship from all ${series.eventCount} events.`)) return
|
||||
if (
|
||||
!confirm(
|
||||
`Delete the entire "${series.title}" series? This will remove the series relationship from all ${series.eventCount} events.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
// Update all events to remove series relationship
|
||||
for (const event of series.events) {
|
||||
await $fetch(`/api/admin/events/${event.id}`, {
|
||||
method: 'PUT',
|
||||
method: "PUT",
|
||||
body: {
|
||||
...event,
|
||||
series: {
|
||||
isSeriesEvent: false,
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'workshop_series',
|
||||
id: "",
|
||||
title: "",
|
||||
description: "",
|
||||
type: "workshop_series",
|
||||
position: 1,
|
||||
totalEvents: null
|
||||
}
|
||||
}
|
||||
})
|
||||
totalEvents: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await refresh()
|
||||
alert('Series deleted and events converted to standalone events')
|
||||
await refresh();
|
||||
alert("Series deleted and events converted to standalone events");
|
||||
} catch (error) {
|
||||
console.error('Failed to delete series:', error)
|
||||
alert('Failed to delete series')
|
||||
}
|
||||
console.error("Failed to delete series:", error);
|
||||
alert("Failed to delete series");
|
||||
}
|
||||
};
|
||||
|
||||
// Bulk operations
|
||||
const reorderAllSeries = async () => {
|
||||
// TODO: Implement auto-reordering
|
||||
alert('Auto-reorder feature coming soon!')
|
||||
}
|
||||
alert("Auto-reorder feature coming soon!");
|
||||
};
|
||||
|
||||
const validateAllSeries = async () => {
|
||||
// TODO: Implement validation
|
||||
alert('Validation feature coming soon!')
|
||||
}
|
||||
alert("Validation feature coming soon!");
|
||||
};
|
||||
|
||||
const exportSeriesData = () => {
|
||||
const dataStr = JSON.stringify(activeSeries.value, null, 2)
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(dataBlob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'event-series-data.json'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
const dataStr = JSON.stringify(activeSeries.value, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: "application/json" });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "event-series-data.json";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
</script>
|
||||
|
|
@ -202,14 +202,10 @@
|
|||
v-if="!event.isCancelled"
|
||||
class="bg-ghost-800 rounded-xl p-8 border border-ghost-700"
|
||||
>
|
||||
<h3 class="text-xl font-bold text-ghost-100 mb-6">
|
||||
Register for This Event
|
||||
</h3>
|
||||
|
||||
<!-- Registration Status -->
|
||||
<div v-if="registrationStatus === 'registered'" class="mb-6">
|
||||
<!-- Already Registered Status -->
|
||||
<div v-if="registrationStatus === 'registered'">
|
||||
<div
|
||||
class="p-4 bg-green-900/20 rounded-lg border border-green-800"
|
||||
class="p-4 bg-green-900/20 rounded-lg border border-green-800 mb-6"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
|
|
@ -233,40 +229,51 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 -->
|
||||
<div v-else-if="event.membersOnly && !isMember" class="text-center">
|
||||
<div
|
||||
v-if="
|
||||
event.membersOnly &&
|
||||
!isMember &&
|
||||
registrationStatus !== 'registered'
|
||||
"
|
||||
class="mb-6"
|
||||
class="p-6 bg-amber-900/20 rounded-lg border border-amber-800 mb-6"
|
||||
>
|
||||
<div
|
||||
class="p-4 bg-amber-900/20 rounded-lg border border-amber-800"
|
||||
>
|
||||
<p class="font-semibold text-amber-300">Membership Required</p>
|
||||
<p class="text-sm text-amber-400 mt-1">
|
||||
<p class="font-semibold text-amber-300 text-lg mb-2">
|
||||
Membership Required
|
||||
</p>
|
||||
<p class="text-amber-400">
|
||||
This event is exclusive to Ghost Guild members. Join any
|
||||
circle to gain access.
|
||||
</p>
|
||||
<NuxtLink
|
||||
to="/join"
|
||||
class="inline-flex items-center text-sm font-medium text-amber-300 hover:underline mt-2"
|
||||
>
|
||||
Become a member →
|
||||
</div>
|
||||
<NuxtLink to="/join">
|
||||
<UButton color="primary" size="xl" class="px-12 py-4">
|
||||
Become a Member to Register
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registration Form -->
|
||||
<form
|
||||
v-if="registrationStatus !== 'registered'"
|
||||
@submit.prevent="handleRegistration"
|
||||
class="space-y-4"
|
||||
>
|
||||
<!-- Show form fields only for public events OR for logged-in members -->
|
||||
<template v-if="!event.membersOnly || isMember">
|
||||
<!-- Not Logged In - Show Registration Form -->
|
||||
<div v-else>
|
||||
<h3 class="text-xl font-bold text-ghost-100 mb-6">
|
||||
Register for This Event
|
||||
</h3>
|
||||
<form @submit.prevent="handleRegistration" class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="name"
|
||||
|
|
@ -312,26 +319,22 @@
|
|||
:options="membershipOptions"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="pt-4">
|
||||
<UButton
|
||||
v-if="!event.membersOnly || isMember"
|
||||
type="submit"
|
||||
color="primary"
|
||||
size="lg"
|
||||
block
|
||||
:loading="isRegistering"
|
||||
>
|
||||
{{ isRegistering ? "Registering..." : "Register for Event" }}
|
||||
{{
|
||||
isRegistering ? "Registering..." : "Register for Event"
|
||||
}}
|
||||
</UButton>
|
||||
<NuxtLink v-else to="/join" class="block">
|
||||
<UButton color="primary" size="lg" block>
|
||||
Become a Member to Register
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Event Capacity -->
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@
|
|||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start gap-2 mb-1">
|
||||
<h3
|
||||
class="text-lg font-semibold text-ghost-100 group-hover:text-blue-400 transition-colors"
|
||||
class="text-lg font-semibold text-ghost-100 group-hover:text-primary transition-colors"
|
||||
>
|
||||
{{ event.title }}
|
||||
</h3>
|
||||
|
|
@ -72,7 +72,7 @@
|
|||
|
||||
<Icon
|
||||
name="heroicons:arrow-right"
|
||||
class="w-5 h-5 text-ghost-400 group-hover:text-blue-400 group-hover:translate-x-1 transition-all flex-shrink-0 mt-1"
|
||||
class="w-5 h-5 text-ghost-400 group-hover:text-primary group-hover:translate-x-1 transition-all flex-shrink-0 mt-1"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
|
@ -128,23 +128,15 @@
|
|||
</section>
|
||||
|
||||
<!-- Event Series -->
|
||||
<section
|
||||
v-if="activeSeries.length > 0"
|
||||
class="py-20 bg-ghost-800 dark:bg-ghost-900"
|
||||
>
|
||||
<UContainer>
|
||||
<div class="text-center mb-12">
|
||||
<div v-if="activeSeries.length > 0" class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-ghost-100 mb-8">
|
||||
Active Event Series
|
||||
Current Event Series
|
||||
</h2>
|
||||
<p class="text-ghost-300 max-w-2xl mx-auto">
|
||||
Multi-part workshops and recurring events designed to deepen your
|
||||
knowledge and build community connections.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto"
|
||||
v-if="activeSeries.length > 0"
|
||||
class="space-y-6 max-w-6xl mx-auto mb-20"
|
||||
>
|
||||
<div
|
||||
v-for="series in activeSeries.slice(0, 6)"
|
||||
|
|
@ -176,7 +168,7 @@
|
|||
|
||||
<div class="space-y-2 mb-4">
|
||||
<div
|
||||
v-for="event in series.events.slice(0, 3)"
|
||||
v-for="(event, index) in series.events.slice(0, 3)"
|
||||
:key="event.id"
|
||||
class="flex items-center justify-between text-xs"
|
||||
>
|
||||
|
|
@ -184,7 +176,7 @@
|
|||
<div
|
||||
class="w-6 h-6 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-full flex items-center justify-center text-xs font-medium"
|
||||
>
|
||||
{{ event.series?.position || "?" }}
|
||||
{{ event.series?.position || index + 1 }}
|
||||
</div>
|
||||
<span class="text-ghost-300 truncate">{{ event.title }}</span>
|
||||
</div>
|
||||
|
|
@ -219,8 +211,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UContainer>
|
||||
</section>
|
||||
|
||||
<!-- Attend Our Events -->
|
||||
<section class="py-20 bg-ghost-800 dark:bg-ghost-900">
|
||||
|
|
@ -354,11 +344,12 @@ const upcomingEvents = computed(() => {
|
|||
|
||||
// Format event date for display
|
||||
const formatEventDate = (date) => {
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
}).format(dateObj);
|
||||
};
|
||||
|
||||
// Get optimized Cloudinary image URL
|
||||
|
|
|
|||
|
|
@ -1,15 +1,23 @@
|
|||
<template>
|
||||
<div>
|
||||
<!-- Page Header -->
|
||||
<!-- Page Header - Context aware -->
|
||||
<PageHeader
|
||||
v-if="!isAuthenticated"
|
||||
title="Join Ghost Guild"
|
||||
subtitle="Become a member of our community and start building a more worker-centric future for games."
|
||||
theme="gray"
|
||||
size="large"
|
||||
/>
|
||||
<PageHeader
|
||||
v-else
|
||||
title="You're Already a Member!"
|
||||
:subtitle="`Welcome back, ${memberData?.name || 'member'}. You're already part of Ghost Guild in the ${memberData?.circle || 'community'} circle.`"
|
||||
theme="gray"
|
||||
size="large"
|
||||
/>
|
||||
|
||||
<!-- Membership Sign Up Form -->
|
||||
<section class="py-20 bg-[--ui-bg]">
|
||||
<section v-if="!isAuthenticated" class="py-20 bg-[--ui-bg]">
|
||||
<UContainer class="max-w-4xl">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-[--ui-text] mb-4">
|
||||
|
|
@ -325,6 +333,59 @@
|
|||
</UContainer>
|
||||
</section>
|
||||
|
||||
<!-- Member Info Section - Shows for logged-in members -->
|
||||
<section v-if="isAuthenticated" class="py-20 bg-[--ui-bg]">
|
||||
<UContainer class="max-w-4xl">
|
||||
<div class="bg-[--ui-bg-elevated] rounded-xl p-8 mb-8">
|
||||
<h2 class="text-2xl font-bold text-[--ui-text] mb-6">
|
||||
Your Membership
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div class="bg-[--ui-bg] rounded-lg p-6">
|
||||
<h3 class="text-sm font-medium text-[--ui-text-muted] mb-2">
|
||||
Circle
|
||||
</h3>
|
||||
<p class="text-xl font-semibold text-[--ui-text] capitalize">
|
||||
{{ memberData?.circle || "Community" }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-[--ui-bg] rounded-lg p-6">
|
||||
<h3 class="text-sm font-medium text-[--ui-text-muted] mb-2">
|
||||
Contribution
|
||||
</h3>
|
||||
<p class="text-xl font-semibold text-[--ui-text]">
|
||||
${{ memberData?.contributionTier || "0" }} CAD/month
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<UButton to="/member/dashboard" size="lg">
|
||||
Go to Dashboard
|
||||
</UButton>
|
||||
<UButton to="/member/profile" variant="outline" size="lg">
|
||||
Edit Profile
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold text-[--ui-text] mb-3">
|
||||
Want to change your circle or contribution?
|
||||
</h3>
|
||||
<p class="text-[--ui-text] mb-4">
|
||||
You can update your circle and adjust your monthly contribution at
|
||||
any time from your profile settings.
|
||||
</p>
|
||||
<UButton to="/member/profile" variant="soft" color="primary">
|
||||
Update Membership Settings
|
||||
</UButton>
|
||||
</div>
|
||||
</UContainer>
|
||||
</section>
|
||||
|
||||
<!-- How Ghost Guild Works -->
|
||||
<section class="py-20 bg-[--ui-bg-elevated]">
|
||||
<UContainer>
|
||||
|
|
@ -361,10 +422,10 @@
|
|||
Circle-Specific Guidance
|
||||
</h3>
|
||||
<ul class="text-[--ui-text] space-y-2">
|
||||
<li>Curated resources for your stage</li>
|
||||
<li>Connection with peers on similar journeys</li>
|
||||
<li>Relevant workshop recommendations</li>
|
||||
<li>Targeted support for your challenges</li>
|
||||
<li>Resources for your stage</li>
|
||||
<li>Connection with peers</li>
|
||||
<li>Workshop recommendations</li>
|
||||
<li>Support for your challenges</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -453,6 +514,14 @@ import {
|
|||
getContributionTierByValue,
|
||||
} from "~/config/contributions";
|
||||
|
||||
// Auth state
|
||||
const { isAuthenticated, memberData, checkMemberStatus } = useAuth();
|
||||
|
||||
// Check authentication status on mount
|
||||
onMounted(async () => {
|
||||
await checkMemberStatus();
|
||||
});
|
||||
|
||||
// Form state
|
||||
const form = reactive({
|
||||
email: "",
|
||||
|
|
@ -492,7 +561,6 @@ const {
|
|||
verifyPayment,
|
||||
cleanup: cleanupHelcimPay,
|
||||
} = useHelcimPay();
|
||||
const { checkMemberStatus } = useAuth();
|
||||
|
||||
// Form validation
|
||||
const isFormValid = computed(() => {
|
||||
|
|
|
|||
|
|
@ -173,6 +173,19 @@
|
|||
<h2 class="text-xl font-bold text-ghost-100 ethereal-text">
|
||||
Your Upcoming Events
|
||||
</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
|
||||
to="/events"
|
||||
variant="ghost"
|
||||
|
|
@ -182,6 +195,7 @@
|
|||
Browse All Events
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loadingEvents" class="text-center py-8">
|
||||
|
|
@ -261,6 +275,46 @@
|
|||
Browse Events
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Calendar subscription instructions -->
|
||||
<div
|
||||
v-if="registeredEvents.length > 0 && showCalendarInstructions"
|
||||
class="mt-4 p-4 bg-ghost-800 border border-ghost-600"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<h4 class="text-sm font-semibold text-ghost-100 mb-2">
|
||||
How to Subscribe to Your Calendar
|
||||
</h4>
|
||||
<ul
|
||||
class="text-xs text-ghost-300 space-y-1 list-disc list-inside"
|
||||
>
|
||||
<li>
|
||||
<strong>Google Calendar:</strong> Click "+" → "From URL" →
|
||||
Paste the link
|
||||
</li>
|
||||
<li>
|
||||
<strong>Apple Calendar:</strong> File → New Calendar
|
||||
Subscription → Paste the link
|
||||
</li>
|
||||
<li>
|
||||
<strong>Outlook:</strong> Add Calendar → Subscribe from web
|
||||
→ Paste the link
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-xs text-ghost-400 mt-2">
|
||||
Your calendar will automatically update when you register or
|
||||
unregister from events.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="showCalendarInstructions = false"
|
||||
class="text-ghost-400 hover:text-ghost-200"
|
||||
>
|
||||
<Icon name="heroicons:x-mark" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</UContainer>
|
||||
|
|
@ -272,6 +326,33 @@ const { memberData, checkMemberStatus } = useAuth();
|
|||
|
||||
const registeredEvents = ref([]);
|
||||
const loadingEvents = ref(false);
|
||||
const calendarLinkCopied = ref(false);
|
||||
const showCalendarInstructions = ref(false);
|
||||
|
||||
// Calendar subscription URL
|
||||
const calendarUrl = computed(() => {
|
||||
const memberId = memberData.value?._id || memberData.value?.id;
|
||||
if (!memberId) return "";
|
||||
const config = useRuntimeConfig();
|
||||
const baseUrl = config.public.appUrl || "http://localhost:3000";
|
||||
// Use webcal protocol for calendar subscription
|
||||
const webcalUrl = baseUrl.replace(/^https?:/, "webcal:");
|
||||
return `${webcalUrl}/api/members/my-calendar?memberId=${memberId}`;
|
||||
});
|
||||
|
||||
// Copy calendar subscription link to clipboard
|
||||
const copyCalendarLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(calendarUrl.value);
|
||||
calendarLinkCopied.value = true;
|
||||
showCalendarInstructions.value = true;
|
||||
setTimeout(() => {
|
||||
calendarLinkCopied.value = false;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy calendar link:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle authentication check on page load
|
||||
const { pending: authPending } = await useLazyAsyncData(
|
||||
|
|
|
|||
|
|
@ -343,20 +343,6 @@
|
|||
Peer Support
|
||||
</h2>
|
||||
|
||||
<div
|
||||
class="mb-6 backdrop-blur-sm bg-purple-500/10 border border-purple-500/30 rounded-lg p-4"
|
||||
>
|
||||
<p class="text-ghost-300 text-sm leading-relaxed">
|
||||
Offer guidance to fellow members through the
|
||||
<NuxtLink
|
||||
to="/peer-support"
|
||||
class="text-purple-400 hover:text-purple-300 underline"
|
||||
>
|
||||
Peer Support directory
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Enable Toggle -->
|
||||
<div class="flex items-start gap-4">
|
||||
|
|
@ -416,7 +402,7 @@
|
|||
!formData.peerSupportSkillTopics?.includes(t),
|
||||
)"
|
||||
:key="tag"
|
||||
class="inline-block ml-2 text-blue-400 hover:text-blue-300 cursor-pointer underline"
|
||||
class="inline-block ml-2 text-primary-400 hover:text-primary-300 cursor-pointer underline"
|
||||
@click="addSuggestedSkillTopic(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
|
|
@ -640,9 +626,6 @@
|
|||
<p class="font-medium text-ghost-100">
|
||||
{{ tier.label }}
|
||||
</p>
|
||||
<p class="text-sm text-ghost-400 mt-1">
|
||||
{{ tier.features[0] }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedContributionTier === tier.value"
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@
|
|||
<button
|
||||
v-if="availableSkills && availableSkills.length > 10"
|
||||
type="button"
|
||||
class="px-3 py-1 text-sm text-purple-400 hover:text-purple-300"
|
||||
class="px-3 py-1 text-sm text-primary hover:text-primary-600"
|
||||
@click="showAllSkills = !showAllSkills"
|
||||
>
|
||||
{{
|
||||
|
|
@ -104,7 +104,7 @@
|
|||
<button
|
||||
v-if="availableTopics && availableTopics.length > 10"
|
||||
type="button"
|
||||
class="px-3 py-1 text-sm text-purple-400 hover:text-purple-300"
|
||||
class="px-3 py-1 text-sm text-primary hover:text-primary-600"
|
||||
@click="showAllTopics = !showAllTopics"
|
||||
>
|
||||
{{
|
||||
|
|
@ -134,7 +134,7 @@
|
|||
{{ circleLabels[selectedCircle] }}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-purple-200"
|
||||
class="hover:text-primary"
|
||||
@click="clearCircleFilter"
|
||||
>
|
||||
×
|
||||
|
|
@ -147,7 +147,7 @@
|
|||
Offering Peer Support
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-purple-200"
|
||||
class="hover:text-primary"
|
||||
@click="clearPeerSupportFilter"
|
||||
>
|
||||
×
|
||||
|
|
@ -156,7 +156,7 @@
|
|||
<button
|
||||
v-if="selectedSkills.length > 0 || selectedTopics.length > 0"
|
||||
type="button"
|
||||
class="text-purple-400 hover:text-purple-300"
|
||||
class="text-primary hover:text-primary-600"
|
||||
@click="clearAllFilters"
|
||||
>
|
||||
Clear all filters
|
||||
|
|
|
|||
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) => {
|
||||
const id = getRouterParam(event, 'id');
|
||||
const id = getRouterParam(event, "id");
|
||||
const body = await readBody(event);
|
||||
const { email } = body;
|
||||
|
||||
if (!email) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Email is required'
|
||||
statusMessage: "Email is required",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -24,22 +25,31 @@ export default defineEventHandler(async (event) => {
|
|||
if (!eventDoc) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Event not found'
|
||||
statusMessage: "Event not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Find the registration index
|
||||
const registrationIndex = eventDoc.registrations.findIndex(
|
||||
registration => registration.email.toLowerCase() === email.toLowerCase()
|
||||
(registration) =>
|
||||
registration.email.toLowerCase() === email.toLowerCase(),
|
||||
);
|
||||
|
||||
if (registrationIndex === -1) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Registration not found'
|
||||
statusMessage: "Registration not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Store registration data before removing (convert to plain object)
|
||||
const registration = {
|
||||
name: eventDoc.registrations[registrationIndex].name,
|
||||
email: eventDoc.registrations[registrationIndex].email,
|
||||
membershipLevel:
|
||||
eventDoc.registrations[registrationIndex].membershipLevel,
|
||||
};
|
||||
|
||||
// Remove the registration
|
||||
eventDoc.registrations.splice(registrationIndex, 1);
|
||||
|
||||
|
|
@ -48,13 +58,26 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
await eventDoc.save();
|
||||
|
||||
// Send cancellation confirmation email
|
||||
try {
|
||||
const eventData = {
|
||||
title: eventDoc.title,
|
||||
slug: eventDoc.slug,
|
||||
_id: eventDoc._id,
|
||||
};
|
||||
await sendEventCancellationEmail(registration, eventData);
|
||||
} catch (emailError) {
|
||||
// Log error but don't fail the cancellation
|
||||
console.error("Failed to send cancellation email:", emailError);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Registration cancelled successfully',
|
||||
registeredCount: eventDoc.registeredCount
|
||||
message: "Registration cancelled successfully",
|
||||
registeredCount: eventDoc.registeredCount,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error cancelling registration:', error);
|
||||
console.error("Error cancelling registration:", error);
|
||||
|
||||
// Re-throw known errors
|
||||
if (error.statusCode) {
|
||||
|
|
@ -63,7 +86,7 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to cancel registration'
|
||||
statusMessage: "Failed to cancel registration",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import Event from "../../../models/event.js";
|
||||
import Member from "../../../models/member.js";
|
||||
import { connectDB } from "../../../utils/mongoose.js";
|
||||
import { sendEventRegistrationEmail } from "../../../utils/resend.js";
|
||||
import mongoose from "mongoose";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
|
|
@ -102,7 +103,7 @@ export default defineEventHandler(async (event) => {
|
|||
}
|
||||
|
||||
// Add registration
|
||||
eventData.registrations.push({
|
||||
const registration = {
|
||||
memberId: member ? member._id : null,
|
||||
name: body.name,
|
||||
email: body.email.toLowerCase(),
|
||||
|
|
@ -112,13 +113,20 @@ export default defineEventHandler(async (event) => {
|
|||
amountPaid: 0,
|
||||
dietary: body.dietary || false,
|
||||
registeredAt: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
eventData.registrations.push(registration);
|
||||
|
||||
// Save the updated event
|
||||
await eventData.save();
|
||||
|
||||
// TODO: Send confirmation email using Resend
|
||||
// await sendEventRegistrationEmail(body.email, eventData)
|
||||
// Send confirmation email using Resend
|
||||
try {
|
||||
await sendEventRegistrationEmail(registration, eventData);
|
||||
} catch (emailError) {
|
||||
// Log error but don't fail the registration
|
||||
console.error("Failed to send confirmation email:", emailError);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
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",
|
||||
tier: "free",
|
||||
helcimPlanId: null, // No Helcim plan needed for free tier
|
||||
features: ["Access to basic resources", "Community forum access"],
|
||||
},
|
||||
SUPPORTER: {
|
||||
value: "5",
|
||||
amount: 5,
|
||||
label: "$5 - I can contribute a little",
|
||||
label: "$5 - I can contribute",
|
||||
tier: "supporter",
|
||||
helcimPlanId: 20162,
|
||||
features: [
|
||||
"All Free Membership benefits",
|
||||
"Priority community support",
|
||||
"Early access to events",
|
||||
],
|
||||
},
|
||||
MEMBER: {
|
||||
value: "15",
|
||||
|
|
@ -29,12 +23,6 @@ export const CONTRIBUTION_TIERS = {
|
|||
label: "$15 - I can sustain the community",
|
||||
tier: "member",
|
||||
helcimPlanId: 21596,
|
||||
features: [
|
||||
"All Supporter benefits",
|
||||
"Access to premium workshops",
|
||||
"Monthly 1-on-1 sessions",
|
||||
"Advanced resource library",
|
||||
],
|
||||
},
|
||||
ADVOCATE: {
|
||||
value: "30",
|
||||
|
|
@ -42,12 +30,6 @@ export const CONTRIBUTION_TIERS = {
|
|||
label: "$30 - I can support others too",
|
||||
tier: "advocate",
|
||||
helcimPlanId: 21597,
|
||||
features: [
|
||||
"All Member benefits",
|
||||
"Weekly group mentoring",
|
||||
"Access to exclusive events",
|
||||
"Direct messaging with experts",
|
||||
],
|
||||
},
|
||||
CHAMPION: {
|
||||
value: "50",
|
||||
|
|
@ -55,13 +37,6 @@ export const CONTRIBUTION_TIERS = {
|
|||
label: "$50 - I want to sponsor multiple members",
|
||||
tier: "champion",
|
||||
helcimPlanId: 21598,
|
||||
features: [
|
||||
"All Advocate benefits",
|
||||
"Personal mentoring sessions",
|
||||
"VIP event access",
|
||||
"Custom project support",
|
||||
"Annual strategy session",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
263
server/utils/resend.js
Normal file
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