diff --git a/app/pages/admin/members.vue b/app/pages/admin/members.vue index 0ad34b8..103dbfc 100644 --- a/app/pages/admin/members.vue +++ b/app/pages/admin/members.vue @@ -30,12 +30,27 @@ - +
|
+ |
@@ -79,7 +100,12 @@ | - Slack Status + Invite + | ++ Slack | + |
+ |
{{ member.name }}
@@ -124,6 +156,18 @@
${{ member.contributionTier }}/month
|
+ + + {{ member.inviteEmailSent ? 'Sent' : 'Not sent' }} + + |
+
+
+
+
+
+
+
+
+ Import Members from CSV+ +
+
+
+
+
+
+
+
+
+ Upload a CSV file with columns: + Valid circles: community, founder, practitioner. Valid tiers: 0, 5, 15, 30, 50. + + +
+
+
+
+ {{ csvParseError }} +
+
+
+
+
+ + {{ csvRows.length }} row{{ csvRows.length !== 1 ? 's' : '' }} parsed. + + {{ csvRows.length - csvValidRows.length }} with errors. + + + +
+
+
+
+
+
+ Import complete +{{ importResults.created }} created +{{ importResults.failed }} failed +
+
+ + {{ fail.email }}: {{ fail.error }} + +
+
+
+
+
+
@@ -263,6 +498,8 @@ definePageMeta({
layout: "admin",
});
+const toast = useToast();
+
const {
data: members,
pending,
@@ -275,6 +512,35 @@ const circleFilter = ref("");
const showCreateModal = ref(false);
const creating = ref(false);
+// Selection
+const selectedMemberIds = ref([]);
+
+// CSV import
+const showImportModal = ref(false);
+const csvFileInput = ref(null);
+const csvRows = ref([]);
+const csvParseError = ref("");
+const importing = ref(false);
+const importResults = ref(null);
+
+// Invite
+const showInviteModal = ref(false);
+const sendingInvites = ref(false);
+const inviteResults = ref(null);
+
+const DEFAULT_INVITE_TEMPLATE = `Hi {name},
+
+You've been invited to Ghost Guild as a member of the {circle} circle.
+
+Sign in here to get started:
+{loginLink}
+
+This link expires in 48 hours. After that, you can always request a new login link at https://ghostguild.org/login.
+
+See you inside.`;
+
+const inviteTemplate = ref(DEFAULT_INVITE_TEMPLATE);
+
const newMember = reactive({
name: "",
email: "",
@@ -298,6 +564,58 @@ const filteredMembers = computed(() => {
});
});
+// Selection helpers
+const allVisibleSelected = computed(() => {
+ if (!filteredMembers.value.length) return false;
+ return filteredMembers.value.every((m) =>
+ selectedMemberIds.value.includes(m._id)
+ );
+});
+
+const someVisibleSelected = computed(() => {
+ return filteredMembers.value.some((m) =>
+ selectedMemberIds.value.includes(m._id)
+ );
+});
+
+const toggleSelectAll = () => {
+ if (allVisibleSelected.value) {
+ const visibleIds = new Set(filteredMembers.value.map((m) => m._id));
+ selectedMemberIds.value = selectedMemberIds.value.filter(
+ (id) => !visibleIds.has(id)
+ );
+ } else {
+ const currentSet = new Set(selectedMemberIds.value);
+ for (const m of filteredMembers.value) {
+ currentSet.add(m._id);
+ }
+ selectedMemberIds.value = [...currentSet];
+ }
+};
+
+const toggleSelect = (id) => {
+ const idx = selectedMemberIds.value.indexOf(id);
+ if (idx >= 0) {
+ selectedMemberIds.value.splice(idx, 1);
+ } else {
+ selectedMemberIds.value.push(id);
+ }
+};
+
+// Invite preview
+const invitePreviewMember = computed(() => {
+ if (!selectedMemberIds.value.length || !members.value) return null;
+ return members.value.find((m) => m._id === selectedMemberIds.value[0]);
+});
+
+const invitePreviewText = computed(() => {
+ if (!invitePreviewMember.value) return "";
+ return inviteTemplate.value
+ .replace(/\{name\}/g, invitePreviewMember.value.name)
+ .replace(/\{loginLink\}/g, "https://ghostguild.org/api/auth/verify?token=...")
+ .replace(/\{circle\}/g, invitePreviewMember.value.circle);
+});
+
const getCircleClasses = (circle) => {
const classes = {
community: "bg-candlelight-900/20 text-candlelight-400",
@@ -311,6 +629,7 @@ const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString();
};
+// --- Create Member ---
const createMember = async () => {
creating.value = true;
try {
@@ -328,15 +647,171 @@ const createMember = async () => {
});
await refresh();
- alert("Member created successfully!");
- } catch (error) {
- console.error("Failed to create member:", error);
- alert("Failed to create member");
+ toast.add({ title: "Member created", color: "green" });
+ } catch (err) {
+ console.error("Failed to create member:", err);
+ toast.add({
+ title: "Failed to create member",
+ description: err.data?.statusMessage || err.message,
+ color: "red",
+ });
} finally {
creating.value = false;
}
};
+// --- CSV Import ---
+const VALID_CIRCLES = ["community", "founder", "practitioner"];
+const VALID_TIERS = ["0", "5", "15", "30", "50"];
+
+const handleCsvFile = (event) => {
+ const file = event.target.files[0];
+ if (!file) return;
+
+ csvParseError.value = "";
+ csvRows.value = [];
+
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ const text = e.target.result;
+ parseCsv(text);
+ };
+ reader.readAsText(file);
+};
+
+const parseCsv = (text) => {
+ const lines = text.split(/\r?\n/).filter((l) => l.trim());
+ if (lines.length < 2) {
+ csvParseError.value = "CSV must have a header row and at least one data row.";
+ return;
+ }
+
+ const header = lines[0].split(",").map((h) => h.trim().toLowerCase());
+ const nameIdx = header.indexOf("name");
+ const emailIdx = header.indexOf("email");
+ const circleIdx = header.indexOf("circle");
+ const tierIdx = header.indexOf("contributiontier");
+
+ if (nameIdx === -1 || emailIdx === -1 || circleIdx === -1 || tierIdx === -1) {
+ csvParseError.value = `Missing required columns. Found: ${header.join(", ")}. Need: name, email, circle, contributionTier`;
+ return;
+ }
+
+ const seenEmails = new Set();
+ const rows = [];
+
+ for (let i = 1; i < lines.length; i++) {
+ const cols = lines[i].split(",").map((c) => c.trim());
+ const name = cols[nameIdx] || "";
+ const email = (cols[emailIdx] || "").toLowerCase();
+ const circle = (cols[circleIdx] || "").toLowerCase();
+ const contributionTier = cols[tierIdx] || "";
+
+ let error = null;
+ if (!name) error = "Missing name";
+ else if (!email || !email.includes("@")) error = "Invalid email";
+ else if (!VALID_CIRCLES.includes(circle)) error = `Invalid circle: ${circle}`;
+ else if (!VALID_TIERS.includes(contributionTier)) error = `Invalid tier: ${contributionTier}`;
+ else if (seenEmails.has(email)) error = "Duplicate email in CSV";
+
+ if (!error) seenEmails.add(email);
+
+ rows.push({ name, email, circle, contributionTier, error });
+ }
+
+ csvRows.value = rows;
+};
+
+const csvValidRows = computed(() => csvRows.value.filter((r) => !r.error));
+
+const resetCsvImport = () => {
+ csvRows.value = [];
+ csvParseError.value = "";
+ importResults.value = null;
+ if (csvFileInput.value) csvFileInput.value.value = "";
+};
+
+const closeImportModal = () => {
+ showImportModal.value = false;
+ if (importResults.value) {
+ resetCsvImport();
+ refresh();
+ }
+};
+
+const submitImport = async () => {
+ importing.value = true;
+ try {
+ const payload = csvValidRows.value.map(({ name, email, circle, contributionTier }) => ({
+ name,
+ email,
+ circle,
+ contributionTier,
+ }));
+
+ const result = await $fetch("/api/admin/members/import", {
+ method: "POST",
+ body: { members: payload },
+ });
+
+ importResults.value = result;
+ toast.add({
+ title: `Imported ${result.created} member${result.created !== 1 ? "s" : ""}`,
+ description: result.failed ? `${result.failed} failed` : undefined,
+ color: result.failed ? "orange" : "green",
+ });
+ } catch (err) {
+ console.error("Import failed:", err);
+ toast.add({
+ title: "Import failed",
+ description: err.data?.statusMessage || err.message,
+ color: "red",
+ });
+ } finally {
+ importing.value = false;
+ }
+};
+
+// --- Send Invites ---
+const openInviteModal = () => {
+ inviteResults.value = null;
+ inviteTemplate.value = DEFAULT_INVITE_TEMPLATE;
+ showInviteModal.value = true;
+};
+
+const submitInvites = async () => {
+ sendingInvites.value = true;
+ try {
+ const result = await $fetch("/api/admin/members/invite", {
+ method: "POST",
+ body: {
+ memberIds: selectedMemberIds.value,
+ emailTemplate: inviteTemplate.value,
+ },
+ });
+
+ inviteResults.value = result;
+ await refresh();
+ selectedMemberIds.value = [];
+
+ toast.add({
+ title: `Sent ${result.sent} invite${result.sent !== 1 ? "s" : ""}`,
+ description: result.failed ? `${result.failed} failed` : undefined,
+ color: result.failed ? "orange" : "green",
+ });
+ } catch (err) {
+ console.error("Failed to send invites:", err);
+ toast.add({
+ title: "Failed to send invites",
+ description: err.data?.statusMessage || err.message,
+ color: "red",
+ });
+ } finally {
+ sendingInvites.value = false;
+ }
+};
+
+// --- Existing actions ---
const sendSlackInvite = (member) => {
alert(`Slack invite functionality would send invite to ${member.email}`);
console.log("Send Slack invite to:", member.email);
+
+
+
+
+ Send Invite Emails+ +
+
+
+ + Sending to {{ selectedMemberIds.length }} + member{{ selectedMemberIds.length !== 1 ? 's' : '' }}. + Each will receive a unique magic login link valid for 48 hours. + + + +
+
+
+
+
+
+
+ Available tokens:
+
+
+
+
+ {{ invitePreviewText }}
+
+
+ Invites sent +{{ inviteResults.sent }} sent +{{ inviteResults.failed }} failed +
+
+ + {{ fail.email }}: {{ fail.error }} + +
+
+
+
+ |
|---|