345 lines
11 KiB
Vue
345 lines
11 KiB
Vue
<template>
|
|
<div>
|
|
<div class="bg-white border-b">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
<!-- 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"
|
|
>
|
|
<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"
|
|
>
|
|
Add Member
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Members Table -->
|
|
<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>
|
|
Loading members...
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="error" class="p-8 text-center text-red-600">
|
|
Error loading members: {{ error }}
|
|
</div>
|
|
|
|
<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>
|
|
</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"
|
|
>
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
<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"
|
|
>
|
|
{{ 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"
|
|
>
|
|
${{ 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>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
|
{{ formatDate(member.createdAt) }}
|
|
</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-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"
|
|
>
|
|
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 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>
|
|
</div>
|
|
|
|
<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"
|
|
/>
|
|
</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"
|
|
/>
|
|
</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"
|
|
>
|
|
<option value="community">Community</option>
|
|
<option value="founder">Founder</option>
|
|
<option value="practitioner">Practitioner</option>
|
|
</select>
|
|
</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"
|
|
>
|
|
<option value="0">$0/month</option>
|
|
<option value="5">$5/month</option>
|
|
<option value="15">$15/month</option>
|
|
<option value="30">$30/month</option>
|
|
<option value="50">$50/month</option>
|
|
</select>
|
|
</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"
|
|
>
|
|
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>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
definePageMeta({
|
|
layout: "admin",
|
|
});
|
|
|
|
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 newMember = reactive({
|
|
name: "",
|
|
email: "",
|
|
circle: "community",
|
|
contributionTier: "0",
|
|
});
|
|
|
|
const filteredMembers = computed(() => {
|
|
if (!members.value) return [];
|
|
|
|
return members.value.filter((member) => {
|
|
const matchesSearch =
|
|
!searchQuery.value ||
|
|
member.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
|
member.email.toLowerCase().includes(searchQuery.value.toLowerCase());
|
|
|
|
const matchesCircle =
|
|
!circleFilter.value || member.circle === circleFilter.value;
|
|
|
|
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";
|
|
};
|
|
|
|
const formatDate = (dateString) => {
|
|
return new Date(dateString).toLocaleDateString();
|
|
};
|
|
|
|
const createMember = async () => {
|
|
creating.value = true;
|
|
try {
|
|
await $fetch("/api/admin/members", {
|
|
method: "POST",
|
|
body: newMember,
|
|
});
|
|
|
|
showCreateModal.value = false;
|
|
Object.assign(newMember, {
|
|
name: "",
|
|
email: "",
|
|
circle: "community",
|
|
contributionTier: "0",
|
|
});
|
|
|
|
await refresh();
|
|
alert("Member created successfully!");
|
|
} catch (error) {
|
|
console.error("Failed to create member:", error);
|
|
alert("Failed to create member");
|
|
} finally {
|
|
creating.value = false;
|
|
}
|
|
};
|
|
|
|
const sendSlackInvite = (member) => {
|
|
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);
|
|
};
|
|
</script>
|