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 @@ - +
+ + + +
@@ -56,6 +71,12 @@ + + +
+ + @@ -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: name,email,circle,contributionTier +

+

+ 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. + +

+ +
+ +
+ + + + + + + + + + + + + + + + + + + +
StatusNameEmailCircleTier
+ {{ row.error }} + OK + {{ row.name }}{{ row.email }}{{ row.circle }}${{ row.contributionTier }}/mo
+
+ + +
+

Import complete

+

{{ importResults.created }} created

+

{{ importResults.failed }} failed

+
+

+ {{ fail.email }}: {{ fail.error }} +

+
+
+
+
+ +
+ + +
+
+ + + +
+
+
+

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: {name}, {loginLink}, {circle} +

+
+ + +
+ +
{{ invitePreviewText }}
+
+ + +
+

Invites sent

+

{{ inviteResults.sent }} sent

+

{{ inviteResults.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);