Add aria-labels to form controls (selects, checkboxes, switches), set html lang attribute and page title, fix color contrast for --candle-dim and --text-faint tokens, underline inline links, remove opacity hack. Harden dev login endpoints with atomic findOneAndUpdate and tokenVersion in JWT. Update Playwright timeouts and E2E test helpers.
1106 lines
29 KiB
Vue
1106 lines
29 KiB
Vue
<template>
|
|
<div class="admin-members">
|
|
<!-- Page Header -->
|
|
<div class="page-header">
|
|
<div class="header-row">
|
|
<div>
|
|
<h1>Members</h1>
|
|
<p>Manage members, contributions, and access</p>
|
|
</div>
|
|
<div class="header-actions">
|
|
<button class="btn" @click="showImportModal = true">Import CSV</button>
|
|
<button
|
|
class="btn"
|
|
:disabled="selectedMemberIds.length === 0"
|
|
@click="openInviteModal"
|
|
>
|
|
Send Invites ({{ selectedMemberIds.length }})
|
|
</button>
|
|
<button class="btn btn-primary" @click="showCreateModal = true">Add Member</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Search / Filter -->
|
|
<div class="filter-bar">
|
|
<div class="field" style="margin-bottom: 0; flex: 1;">
|
|
<input
|
|
v-model="searchQuery"
|
|
placeholder="Search members..."
|
|
/>
|
|
</div>
|
|
<div class="field" style="margin-bottom: 0;">
|
|
<select v-model="circleFilter" aria-label="Filter by circle">
|
|
<option value="">All Circles</option>
|
|
<option value="community">Community</option>
|
|
<option value="founder">Founder</option>
|
|
<option value="practitioner">Practitioner</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Members Table -->
|
|
<div class="table-wrap">
|
|
<div v-if="pending" class="loading-state">
|
|
<div class="spinner" />
|
|
<span>Loading members...</span>
|
|
</div>
|
|
|
|
<div v-else-if="error" class="error-state">
|
|
Error loading members: {{ error }}
|
|
</div>
|
|
|
|
<table v-else-if="filteredMembers.length">
|
|
<thead>
|
|
<tr>
|
|
<th class="col-check">
|
|
<UCheckbox
|
|
aria-label="Select all members"
|
|
:model-value="allVisibleSelected ? true : (someVisibleSelected ? 'indeterminate' : false)"
|
|
@update:model-value="toggleSelectAll"
|
|
/>
|
|
</th>
|
|
<th>Name</th>
|
|
<th>Email</th>
|
|
<th>Circle</th>
|
|
<th>Tier</th>
|
|
<th>Invite</th>
|
|
<th>Slack</th>
|
|
<th>Joined</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="member in filteredMembers" :key="member._id">
|
|
<td class="col-check">
|
|
<UCheckbox
|
|
:aria-label="`Select ${member.name}`"
|
|
:model-value="selectedMemberIds.includes(member._id)"
|
|
@update:model-value="toggleSelect(member._id)"
|
|
/>
|
|
</td>
|
|
<td class="col-name">{{ member.name }}</td>
|
|
<td class="col-email">{{ member.email }}</td>
|
|
<td><span class="badge" :class="member.circle">{{ member.circle }}</span></td>
|
|
<td class="col-mono">${{ member.contributionTier }}/mo</td>
|
|
<td>
|
|
<span :class="member.inviteEmailSent ? 'status-ok' : 'status-dim'">
|
|
{{ member.inviteEmailSent ? 'Sent' : 'Not sent' }}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<span :class="member.slackInvited ? 'status-ok' : 'status-dim'">
|
|
{{ member.slackInvited ? 'Invited' : 'Pending' }}
|
|
</span>
|
|
</td>
|
|
<td class="col-mono col-date">{{ formatDate(member.createdAt) }}</td>
|
|
<td class="col-actions">
|
|
<button @click="sendSlackInvite(member)" class="link-btn">Slack</button>
|
|
<button @click="editMember(member)" class="link-btn">Edit</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<div v-else class="empty-state">
|
|
No members found matching your criteria
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create Member Modal -->
|
|
<div v-if="showCreateModal" class="modal-overlay" @click.self="showCreateModal = false">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<h2>Add New Member</h2>
|
|
<button class="modal-close" @click="showCreateModal = false">×</button>
|
|
</div>
|
|
|
|
<form @submit.prevent="createMember" class="modal-body">
|
|
<div class="field">
|
|
<label>Name</label>
|
|
<input v-model="newMember.name" placeholder="Full name" required />
|
|
</div>
|
|
<div class="field">
|
|
<label>Email</label>
|
|
<input v-model="newMember.email" type="email" placeholder="email@example.com" required />
|
|
</div>
|
|
<div class="field">
|
|
<label>Circle</label>
|
|
<select v-model="newMember.circle">
|
|
<option value="community">Community</option>
|
|
<option value="founder">Founder</option>
|
|
<option value="practitioner">Practitioner</option>
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label>Contribution Tier</label>
|
|
<select v-model="newMember.contributionTier">
|
|
<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="modal-actions">
|
|
<button type="button" class="btn" @click="showCreateModal = false">Cancel</button>
|
|
<button type="submit" class="btn btn-primary" :disabled="creating">
|
|
{{ creating ? 'Creating...' : 'Create Member' }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CSV Import Modal -->
|
|
<div v-if="showImportModal" class="modal-overlay" @click.self="closeImportModal">
|
|
<div class="modal modal-wide">
|
|
<div class="modal-header">
|
|
<h2>Import Members from CSV</h2>
|
|
<button class="modal-close" @click="closeImportModal">×</button>
|
|
</div>
|
|
|
|
<div class="modal-body">
|
|
<div v-if="!csvRows.length">
|
|
<p class="help-text">
|
|
Upload a CSV with columns: <code>name,email,circle,contributionTier</code>
|
|
</p>
|
|
<p class="help-text" style="margin-bottom: 12px;">
|
|
Valid circles: community, founder, practitioner. Valid tiers: 0, 5, 15, 30, 50.
|
|
</p>
|
|
<input
|
|
ref="csvFileInput"
|
|
type="file"
|
|
accept=".csv"
|
|
@change="handleCsvFile"
|
|
class="file-input"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="csvParseError" class="error-box">
|
|
{{ csvParseError }}
|
|
</div>
|
|
|
|
<div v-if="csvRows.length">
|
|
<div class="csv-summary">
|
|
<span>{{ csvRows.length }} row{{ csvRows.length !== 1 ? 's' : '' }} parsed.</span>
|
|
<span v-if="csvValidRows.length !== csvRows.length" class="csv-errors">
|
|
{{ csvRows.length - csvValidRows.length }} with errors.
|
|
</span>
|
|
<button @click="resetCsvImport" class="link-btn">Choose different file</button>
|
|
</div>
|
|
|
|
<div class="csv-table-wrap">
|
|
<table class="csv-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Status</th>
|
|
<th>Name</th>
|
|
<th>Email</th>
|
|
<th>Circle</th>
|
|
<th>Tier</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(row, i) in csvRows" :key="i" :class="{ 'row-error': row.error }">
|
|
<td>
|
|
<span v-if="row.error" class="status-error">{{ row.error }}</span>
|
|
<span v-else class="status-ok">OK</span>
|
|
</td>
|
|
<td>{{ row.name }}</td>
|
|
<td class="col-email">{{ row.email }}</td>
|
|
<td>{{ row.circle }}</td>
|
|
<td>${{ row.contributionTier }}/mo</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div v-if="importResults" class="results-box">
|
|
<strong>Import complete</strong>
|
|
<p class="status-ok">{{ importResults.created }} created</p>
|
|
<p v-if="importResults.failed" class="status-error">{{ importResults.failed }} failed</p>
|
|
<div v-if="importResults.results?.some(r => !r.success)">
|
|
<p
|
|
v-for="fail in importResults.results.filter(r => !r.success)"
|
|
:key="fail.email"
|
|
class="status-error"
|
|
style="font-size: 11px;"
|
|
>
|
|
{{ fail.email }}: {{ fail.error }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-actions">
|
|
<button @click="closeImportModal" class="btn">
|
|
{{ importResults ? 'Done' : 'Cancel' }}
|
|
</button>
|
|
<button
|
|
v-if="csvValidRows.length && !importResults"
|
|
:disabled="importing"
|
|
@click="submitImport"
|
|
class="btn btn-primary"
|
|
>
|
|
{{ importing ? 'Importing...' : `Import ${csvValidRows.length} member${csvValidRows.length !== 1 ? 's' : ''}` }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Edit Member Modal -->
|
|
<div v-if="showEditModal" class="modal-overlay" @click.self="showEditModal = false">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<h2>Edit Member</h2>
|
|
<button class="modal-close" @click="showEditModal = false">×</button>
|
|
</div>
|
|
|
|
<form @submit.prevent="submitEditMember" class="modal-body">
|
|
<div class="field">
|
|
<label>Name</label>
|
|
<input v-model="editingMember.name" required />
|
|
</div>
|
|
<div class="field">
|
|
<label>Email</label>
|
|
<input v-model="editingMember.email" type="email" required />
|
|
</div>
|
|
<div class="field">
|
|
<label>Circle</label>
|
|
<select v-model="editingMember.circle">
|
|
<option value="community">Community</option>
|
|
<option value="founder">Founder</option>
|
|
<option value="practitioner">Practitioner</option>
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label>Contribution Tier</label>
|
|
<select v-model="editingMember.contributionTier">
|
|
<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="field">
|
|
<label>Status</label>
|
|
<select v-model="editingMember.status">
|
|
<option value="pending_payment">Pending Payment</option>
|
|
<option value="active">Active</option>
|
|
<option value="suspended">Suspended</option>
|
|
<option value="cancelled">Cancelled</option>
|
|
</select>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button type="button" class="btn" @click="showEditModal = false">Cancel</button>
|
|
<button type="submit" class="btn btn-primary" :disabled="saving">
|
|
{{ saving ? 'Saving...' : 'Save Changes' }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Send Invites Modal -->
|
|
<div v-if="showInviteModal" class="modal-overlay" @click.self="showInviteModal = false">
|
|
<div class="modal modal-wide">
|
|
<div class="modal-header">
|
|
<h2>Send Invite Emails</h2>
|
|
<button class="modal-close" @click="showInviteModal = false">×</button>
|
|
</div>
|
|
|
|
<div class="modal-body">
|
|
<p class="help-text">
|
|
Sending to <strong>{{ selectedMemberIds.length }}</strong>
|
|
member{{ selectedMemberIds.length !== 1 ? 's' : '' }}.
|
|
Each receives a unique magic login link valid for 48 hours.
|
|
</p>
|
|
|
|
<div class="field">
|
|
<label>Email Template</label>
|
|
<textarea
|
|
v-model="inviteTemplate"
|
|
rows="12"
|
|
></textarea>
|
|
<p class="help-text" style="margin-top: 4px;">
|
|
Tokens: <code>{name}</code>, <code>{loginLink}</code>, <code>{circle}</code>
|
|
</p>
|
|
</div>
|
|
|
|
<div v-if="invitePreviewMember" class="field">
|
|
<label>Preview ({{ invitePreviewMember.name }})</label>
|
|
<pre class="preview-box">{{ invitePreviewText }}</pre>
|
|
</div>
|
|
|
|
<div v-if="inviteResults" class="results-box">
|
|
<strong>Invites sent</strong>
|
|
<p class="status-ok">{{ inviteResults.sent }} sent</p>
|
|
<p v-if="inviteResults.failed" class="status-error">{{ inviteResults.failed }} failed</p>
|
|
<div v-if="inviteResults.results?.some(r => !r.success)">
|
|
<p
|
|
v-for="fail in inviteResults.results.filter(r => !r.success)"
|
|
:key="fail.email"
|
|
class="status-error"
|
|
style="font-size: 11px;"
|
|
>
|
|
{{ fail.email }}: {{ fail.error }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-actions">
|
|
<button @click="showInviteModal = false" class="btn">
|
|
{{ inviteResults ? 'Done' : 'Cancel' }}
|
|
</button>
|
|
<button
|
|
v-if="!inviteResults"
|
|
:disabled="sendingInvites"
|
|
@click="submitInvites"
|
|
class="btn btn-primary"
|
|
>
|
|
{{ sendingInvites ? 'Sending...' : `Send ${selectedMemberIds.length} invite${selectedMemberIds.length !== 1 ? 's' : ''}` }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
definePageMeta({
|
|
layout: 'admin',
|
|
middleware: 'admin',
|
|
})
|
|
|
|
const toast = useToast()
|
|
|
|
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)
|
|
|
|
// 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: '',
|
|
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
|
|
})
|
|
})
|
|
|
|
// 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 formatDate = (dateString) => {
|
|
return new Date(dateString).toLocaleDateString()
|
|
}
|
|
|
|
// --- Create Member ---
|
|
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()
|
|
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) => {
|
|
console.log('Send Slack invite to:', member.email)
|
|
}
|
|
|
|
// --- Edit Member ---
|
|
const showEditModal = ref(false)
|
|
const saving = ref(false)
|
|
const editingMemberId = ref(null)
|
|
const editingMember = reactive({
|
|
name: '',
|
|
email: '',
|
|
circle: 'community',
|
|
contributionTier: '0',
|
|
status: 'pending_payment',
|
|
})
|
|
|
|
const editMember = (member) => {
|
|
editingMemberId.value = member._id
|
|
Object.assign(editingMember, {
|
|
name: member.name,
|
|
email: member.email,
|
|
circle: member.circle,
|
|
contributionTier: String(member.contributionTier),
|
|
status: member.status || 'pending_payment',
|
|
})
|
|
showEditModal.value = true
|
|
}
|
|
|
|
const submitEditMember = async () => {
|
|
saving.value = true
|
|
try {
|
|
await $fetch(`/api/admin/members/${editingMemberId.value}`, {
|
|
method: 'PUT',
|
|
body: {
|
|
name: editingMember.name,
|
|
email: editingMember.email,
|
|
circle: editingMember.circle,
|
|
contributionTier: editingMember.contributionTier,
|
|
status: editingMember.status,
|
|
},
|
|
})
|
|
showEditModal.value = false
|
|
await refresh()
|
|
toast.add({ title: 'Member updated', color: 'green' })
|
|
} catch (err) {
|
|
toast.add({
|
|
title: 'Failed to update member',
|
|
description: err.data?.statusMessage || err.message,
|
|
color: 'red',
|
|
})
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.admin-members {
|
|
max-width: 1100px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
/* ---- PAGE HEADER ---- */
|
|
.page-header {
|
|
padding: 28px 28px 20px;
|
|
border-bottom: 1px dashed var(--border);
|
|
}
|
|
|
|
.header-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
gap: 16px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.page-header h1 {
|
|
font-family: 'Brygada 1918', serif;
|
|
font-size: 24px;
|
|
font-weight: 500;
|
|
color: var(--text-bright);
|
|
line-height: 1.2;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.page-header p {
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* ---- FILTER BAR ---- */
|
|
.filter-bar {
|
|
display: flex;
|
|
gap: 12px;
|
|
padding: 16px 28px;
|
|
border-bottom: 1px dashed var(--border);
|
|
}
|
|
|
|
/* ---- TABLE ---- */
|
|
.table-wrap {
|
|
padding: 0 28px 28px;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 12px;
|
|
}
|
|
|
|
thead th {
|
|
text-align: left;
|
|
font-size: 10px;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: var(--text-faint);
|
|
padding: 14px 10px 10px;
|
|
border-bottom: 1px dashed var(--border-d);
|
|
font-weight: normal;
|
|
}
|
|
|
|
tbody tr {
|
|
border-bottom: 1px dashed var(--border);
|
|
transition: background 0.1s;
|
|
}
|
|
|
|
tbody tr:hover {
|
|
background: var(--surface);
|
|
}
|
|
|
|
tbody td {
|
|
padding: 10px;
|
|
color: var(--text);
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.col-check {
|
|
width: 32px;
|
|
padding-left: 0;
|
|
padding-right: 4px;
|
|
}
|
|
|
|
.col-name {
|
|
font-weight: 500;
|
|
color: var(--text-bright);
|
|
}
|
|
|
|
.col-email {
|
|
color: var(--text-dim);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.col-mono {
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.col-date {
|
|
font-size: 11px;
|
|
color: var(--text-faint);
|
|
}
|
|
|
|
.col-actions {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* ---- LINK BUTTON ---- */
|
|
.link-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--candle);
|
|
cursor: pointer;
|
|
font-family: 'Commit Mono', monospace;
|
|
font-size: 11px;
|
|
padding: 2px 6px;
|
|
}
|
|
|
|
.link-btn:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
/* ---- STATUS INDICATORS ---- */
|
|
.status-ok {
|
|
color: var(--green);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.status-dim {
|
|
color: var(--text-faint);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.status-error {
|
|
color: var(--ember);
|
|
font-size: 11px;
|
|
}
|
|
|
|
/* ---- MODALS ---- */
|
|
.modal-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.4);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 50;
|
|
}
|
|
|
|
.modal {
|
|
background: var(--bg);
|
|
border: 1px dashed var(--border);
|
|
max-width: 440px;
|
|
width: 100%;
|
|
margin: 16px;
|
|
max-height: 90vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.modal-wide {
|
|
max-width: 640px;
|
|
}
|
|
|
|
.modal-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 20px 24px 16px;
|
|
border-bottom: 1px dashed var(--border);
|
|
}
|
|
|
|
.modal-header h2 {
|
|
font-family: 'Brygada 1918', serif;
|
|
font-size: 18px;
|
|
font-weight: 500;
|
|
color: var(--text-bright);
|
|
}
|
|
|
|
.modal-close {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-faint);
|
|
font-size: 20px;
|
|
cursor: pointer;
|
|
padding: 0 4px;
|
|
line-height: 1;
|
|
}
|
|
|
|
.modal-close:hover {
|
|
color: var(--text);
|
|
}
|
|
|
|
.modal-body {
|
|
padding: 20px 24px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.modal-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
padding: 16px 24px;
|
|
border-top: 1px dashed var(--border);
|
|
}
|
|
|
|
/* ---- HELP TEXT ---- */
|
|
.help-text {
|
|
font-size: 11px;
|
|
color: var(--text-dim);
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.help-text code {
|
|
color: var(--text-bright);
|
|
font-size: 11px;
|
|
}
|
|
|
|
/* ---- FILE INPUT ---- */
|
|
.file-input {
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
/* ---- ERROR / RESULTS BOXES ---- */
|
|
.error-box {
|
|
padding: 12px 16px;
|
|
border: 1px dashed var(--ember);
|
|
color: var(--ember);
|
|
font-size: 12px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.results-box {
|
|
padding: 16px 20px;
|
|
border: 1px dashed var(--border);
|
|
margin-top: 16px;
|
|
}
|
|
|
|
.results-box strong {
|
|
color: var(--text-bright);
|
|
font-size: 13px;
|
|
display: block;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
/* ---- CSV TABLE ---- */
|
|
.csv-summary {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.csv-errors {
|
|
color: var(--ember);
|
|
}
|
|
|
|
.csv-table-wrap {
|
|
max-height: 250px;
|
|
overflow: auto;
|
|
border: 1px dashed var(--border);
|
|
}
|
|
|
|
.csv-table {
|
|
font-size: 11px;
|
|
}
|
|
|
|
.csv-table thead th {
|
|
position: sticky;
|
|
top: 0;
|
|
background: var(--surface);
|
|
z-index: 1;
|
|
}
|
|
|
|
.row-error {
|
|
background: rgba(138, 68, 32, 0.04);
|
|
}
|
|
|
|
/* ---- PREVIEW BOX ---- */
|
|
.preview-box {
|
|
font-family: 'Commit Mono', monospace;
|
|
font-size: 11px;
|
|
color: var(--text-dim);
|
|
background: var(--surface);
|
|
border: 1px dashed var(--border);
|
|
padding: 16px;
|
|
white-space: pre-wrap;
|
|
overflow: auto;
|
|
max-height: 200px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
/* ---- STATES ---- */
|
|
.loading-state {
|
|
text-align: center;
|
|
padding: 48px 24px;
|
|
color: var(--text-faint);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.error-state {
|
|
text-align: center;
|
|
padding: 48px 24px;
|
|
color: var(--ember);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 48px 24px;
|
|
color: var(--text-faint);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.spinner {
|
|
width: 24px;
|
|
height: 24px;
|
|
border: 2px dashed var(--candle);
|
|
border-top-color: transparent;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
margin: 0 auto 12px;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* ---- RESPONSIVE ---- */
|
|
@media (max-width: 768px) {
|
|
.page-header {
|
|
padding: 24px 20px 16px;
|
|
}
|
|
|
|
.header-row {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.header-actions {
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.filter-bar {
|
|
flex-direction: column;
|
|
padding: 12px 20px;
|
|
}
|
|
|
|
.table-wrap {
|
|
padding: 0 12px 20px;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
table {
|
|
min-width: 700px;
|
|
}
|
|
}
|
|
</style>
|