ghostguild-org/app/pages/admin/members/[id].vue
Jennie Robinson Faber cb93f14160 style(visual-fidelity): pages-admin — batches B,C,F
- B: token-equivalent rgba → color-mix(srgb, var(--ember|green|candle) X%, transparent) so colors track dark mode
- C: drop stale var(--green, #...) fallbacks (canonical token now defined in main.css)
- F: inline circle badge → <CircleBadge/> in admin/index, members/[id], members/index
2026-04-30 00:13:09 +01:00

856 lines
21 KiB
Vue

<template>
<div class="admin-member-detail">
<!-- Page Header -->
<div class="page-header">
<div class="header-nav">
<NuxtLink to="/admin/members" class="back-link"> Members</NuxtLink>
<NuxtLink v-if="member && member.status === 'active' && member.showInDirectory" :to="`/members/${member._id}`" class="profile-link" target="_blank">
View public profile ↗
</NuxtLink>
</div>
<div class="header-row">
<div>
<h1 v-if="member">{{ member.name }}</h1>
<h1 v-else-if="pending">Loading…</h1>
<h1 v-else>Member not found</h1>
<p v-if="member" class="member-email">{{ member.email }}</p>
</div>
<div v-if="member" class="header-badges">
<CircleBadge :circle="member.circle" />
<span :class="statusClass(member.status)" class="status-badge">{{ member.status }}</span>
</div>
</div>
</div>
<div v-if="pending" class="loading-state">
<div class="spinner" />
Loading member…
</div>
<div v-else-if="fetchError" class="error-state">Failed to load member.</div>
<template v-else-if="member">
<div class="detail-body">
<!-- LEFT COLUMN: form + metadata -->
<div class="detail-left">
<!-- Edit form -->
<section class="detail-section">
<div class="section-label">Member details</div>
<form class="edit-form" @submit.prevent="submitEdit">
<div class="field">
<label>Name</label>
<input v-model="form.name" type="text" required >
</div>
<div class="field">
<label>Email</label>
<input v-model="form.email" type="email" required >
</div>
<div class="field">
<label>Circle</label>
<select v-model="form.circle">
<option value="community">Community</option>
<option value="founder">Founder</option>
<option value="practitioner">Practitioner</option>
</select>
</div>
<div class="field">
<label>Contribution ($/mo)</label>
<input v-model.number="form.contributionAmount" type="number" min="0" step="1">
<p class="field-hint field-hint--warn">
Writes to our database only. If the member is on a paid plan, also update <code>recurringAmount</code> in the Helcim dashboard — this form does not sync.
</p>
</div>
<div class="field">
<label>Status</label>
<select v-model="form.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="field">
<label>Role</label>
<select v-model="form.role">
<option value="member">member</option>
<option value="admin">admin</option>
</select>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? "Saving" : "Save changes" }}
</button>
<button type="button" class="btn" @click="resetForm">Reset</button>
</div>
</form>
</section>
<!-- Metadata -->
<section class="detail-section">
<div class="section-label">Account info</div>
<dl class="meta-list">
<div v-if="member.memberNumber" class="meta-row">
<dt>Member number</dt>
<dd class="mono">#{{ member.memberNumber }}</dd>
</div>
<div class="meta-row">
<dt>Member ID</dt>
<dd class="mono">{{ member._id }}</dd>
</div>
<div class="meta-row">
<dt>Joined</dt>
<dd>{{ formatDate(member.createdAt) }}</dd>
</div>
<div class="meta-row">
<dt>Invite email</dt>
<dd :class="member.inviteEmailSent ? 'status-ok' : 'status-dim'">
{{ member.inviteEmailSent ? "Sent" : "Not sent" }}
</dd>
</div>
<div class="meta-row">
<dt>Slack invite</dt>
<dd v-if="member.slackInvited" class="status-ok">
Invited {{ formatDate(member.slackInvitedAt) }}
</dd>
<dd v-else class="meta-action">
<span class="status-dim">Not yet invited</span>
<button
type="button"
class="link-btn"
:disabled="markingSlackInvited"
@click="markSlackInvited"
>
{{ markingSlackInvited ? "Marking…" : "Mark as Slack invited" }}
</button>
</dd>
</div>
<div v-if="member.helcimCustomerId" class="meta-row">
<dt>Helcim customer</dt>
<dd class="mono">{{ member.helcimCustomerId }}</dd>
</div>
<div v-if="member.helcimSubscriptionId" class="meta-row">
<dt>Helcim subscription</dt>
<dd class="mono">{{ member.helcimSubscriptionId }}</dd>
</div>
</dl>
</section>
<!-- Onboarding -->
<section class="detail-section">
<div class="section-label">Onboarding</div>
<dl class="meta-list">
<div class="meta-row">
<dt>Profile Tags</dt>
<dd :class="hasProfileTags ? 'status-ok' : 'status-dim'">
{{ hasProfileTags ? '✓ Complete' : '— Incomplete' }}
</dd>
</div>
<div class="meta-row">
<dt>Event Page Visited</dt>
<dd :class="member.onboarding?.eventPageVisited ? 'status-ok' : 'status-dim'">
{{ member.onboarding?.eventPageVisited ? '✓ Complete' : '— Incomplete' }}
</dd>
</div>
<div class="meta-row">
<dt>Board Engaged</dt>
<dd :class="hasBoardEngaged ? 'status-ok' : 'status-dim'">
{{ hasBoardEngaged ? '✓ Complete' : '— Incomplete' }}
</dd>
</div>
<div class="meta-row">
<dt>Wiki Clicked</dt>
<dd :class="member.onboarding?.wikiClicked ? 'status-ok' : 'status-dim'">
{{ member.onboarding?.wikiClicked ? '✓ Complete' : '— Incomplete' }}
</dd>
</div>
<div class="meta-row">
<dt>Completed</dt>
<dd :class="member.onboarding?.completedAt ? 'status-ok' : 'status-dim'">
{{ member.onboarding?.completedAt ? formatDate(member.onboarding.completedAt) : 'In progress' }}
</dd>
</div>
</dl>
</section>
<!-- Notification preferences -->
<section class="detail-section">
<div class="section-label">Notification preferences</div>
<dl class="meta-list">
<div class="meta-row">
<dt>Event reminders</dt>
<dd :class="member.notifications?.events !== false ? 'status-ok' : 'status-dim'">
{{ member.notifications?.events !== false ? "On" : "Off" }}
</dd>
</div>
<div class="meta-row">
<dt>Community updates</dt>
<dd :class="member.notifications?.updates !== false ? 'status-ok' : 'status-dim'">
{{ member.notifications?.updates !== false ? "On" : "Off" }}
</dd>
</div>
</dl>
</section>
</div>
<!-- RIGHT COLUMN: activity log -->
<div class="detail-right">
<div class="activity-panel">
<div class="activity-panel-header">
<div class="section-label">Activity log</div>
<span class="activity-legend">
<span class="al-vis-badge">admin-only</span> = not visible to member
</span>
</div>
<ClientOnly>
<div v-if="activityLoading && !activityEntries.length" class="activity-loading">
<div class="spinner" />
Loading activity...
</div>
<div v-else-if="activityEntries.length" class="activity-timeline">
<div
v-for="entry in activityEntries"
:key="entry._id"
class="al-item"
:class="{ 'al-admin': entry.visibility === 'admin' }"
>
<div class="al-dot" />
<div class="al-body">
<div class="al-row">
<UIcon :name="getActivity(entry).icon" class="al-icon" />
<span class="al-text">{{ getActivity(entry).text }}</span>
<span v-if="entry.visibility === 'admin'" class="al-vis-badge">admin-only</span>
</div>
<span class="al-time">{{ formatDate(entry.timestamp) }}</span>
</div>
</div>
<div v-if="activityHasMore" class="al-load-more">
<button class="btn" :disabled="activityLoadingMore" @click="loadMoreActivity">
{{ activityLoadingMore ? 'Loading...' : 'Load more' }}
</button>
</div>
</div>
<div v-else class="activity-empty">
No activity recorded.
</div>
</ClientOnly>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { formatActivity } from '~/utils/activityText'
definePageMeta({
layout: "admin",
middleware: "admin",
});
const route = useRoute();
const toast = useToast();
const {
data: member,
pending,
error: fetchError,
} = await useFetch(`/api/admin/members/${route.params.id}`);
const pageBreadcrumbTitle = useState("pageBreadcrumbTitle", () => "");
pageBreadcrumbTitle.value = member.value?.name || "";
watch(member, (val) => {
pageBreadcrumbTitle.value = val?.name || "";
});
onUnmounted(() => {
pageBreadcrumbTitle.value = "";
});
const form = reactive({
name: "",
email: "",
circle: "",
contributionAmount: 0,
status: "",
role: "",
});
const saving = ref(false);
function populateForm(m) {
if (!m) return;
form.name = m.name;
form.email = m.email;
form.circle = m.circle;
form.contributionAmount = m.contributionAmount ?? 0;
form.status = m.status || "pending_payment";
form.role = m.role || "member";
}
// Populate once data is ready
if (member.value) populateForm(member.value);
watch(member, populateForm, { immediate: false });
function resetForm() {
populateForm(member.value);
}
async function submitEdit() {
saving.value = true;
try {
const updated = await $fetch(`/api/admin/members/${route.params.id}`, {
method: "PUT",
body: {
name: form.name,
email: form.email,
circle: form.circle,
contributionAmount: form.contributionAmount,
status: form.status,
},
});
// Update role separately if it changed
if (form.role !== member.value?.role) {
await $fetch(`/api/admin/members/${route.params.id}/role`, {
method: "PATCH",
body: { role: form.role },
});
}
// Reflect changes locally
if (member.value) {
member.value = { ...member.value, ...updated, role: form.role };
pageBreadcrumbTitle.value = form.name;
}
toast.add({ title: "Member updated", color: "success" });
} catch (err) {
toast.add({
title: "Failed to update member",
description: err.data?.statusMessage || err.message,
color: "error",
});
} finally {
saving.value = false;
}
}
function formatDate(val) {
if (!val) return "—";
return new Date(val).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function statusClass(status) {
if (status === "active") return "status-ok";
if (status === "cancelled" || status === "suspended") return "status-error";
return "status-dim";
}
// Onboarding computed states
const hasProfileTags = computed(() => {
const m = member.value
if (!m) return false
return m.craftTags?.length > 0 && m.board?.topics?.length > 0
})
const hasBoardEngaged = computed(() => {
const m = member.value
if (!m) return false
return m.onboarding?.boardPageVisited && m.board?.topics?.some(
t => ['help', 'interested', 'seeking'].includes(t.state)
)
})
const markingSlackInvited = ref(false)
async function markSlackInvited() {
if (!member.value || markingSlackInvited.value) return
markingSlackInvited.value = true
try {
const res = await $fetch(
`/api/admin/members/${route.params.id}/slack-status`,
{
method: "PATCH",
body: { slackInvited: true },
},
)
member.value = { ...member.value, ...res.member }
toast.add({ title: "Marked as Slack invited", color: "success" })
} catch (err) {
toast.add({
title: "Failed to mark Slack invited",
description: err.data?.statusMessage || err.message,
color: "error",
})
} finally {
markingSlackInvited.value = false
}
}
// Activity log
const activityEntries = ref([])
const activityLoading = ref(false)
const activityLoadingMore = ref(false)
const activityHasMore = ref(false)
const activityNextCursor = ref(null)
const getActivity = (entry) => formatActivity(entry)
async function loadActivity() {
activityLoading.value = true
try {
const data = await $fetch(`/api/admin/members/${route.params.id}/activity`, {
params: { limit: 20 }
})
activityEntries.value = data.entries
activityHasMore.value = data.hasMore
activityNextCursor.value = data.nextCursor
} catch (err) {
console.error('Failed to load activity:', err)
} finally {
activityLoading.value = false
}
}
async function loadMoreActivity() {
if (!activityNextCursor.value) return
activityLoadingMore.value = true
try {
const data = await $fetch(`/api/admin/members/${route.params.id}/activity`, {
params: { limit: 20, before: activityNextCursor.value }
})
activityEntries.value.push(...data.entries)
activityHasMore.value = data.hasMore
activityNextCursor.value = data.nextCursor
} catch (err) {
console.error('Failed to load more activity:', err)
} finally {
activityLoadingMore.value = false
}
}
onMounted(loadActivity)
</script>
<style scoped>
.admin-member-detail {}
/* ---- PAGE HEADER ---- */
.page-header {
padding: 28px 28px 20px;
border-bottom: 1px dashed var(--border);
}
.header-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.back-link {
font-size: 11px;
color: var(--text-faint);
text-decoration: none;
letter-spacing: 0.02em;
}
.back-link:hover {
color: var(--candle);
text-decoration: none;
}
.profile-link {
font-size: 11px;
color: var(--candle);
text-decoration: none;
letter-spacing: 0.02em;
}
.profile-link:hover {
text-decoration: underline;
}
.header-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.page-header h1 {
font-family: "Brygada 1918", serif;
font-size: 24px;
font-weight: 500;
color: var(--text-bright);
margin: 0 0 4px;
line-height: 1.2;
}
.member-email {
font-size: 12px;
color: var(--text-faint);
margin: 0;
}
.header-badges {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding-top: 4px;
}
.status-badge {
font-size: 10px;
font-family: "Commit Mono", monospace;
padding: 2px 8px;
border: 1px dashed var(--border);
color: var(--text-dim);
letter-spacing: 0.06em;
text-transform: uppercase;
}
/* ---- TWO-COLUMN BODY ---- */
.detail-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 0;
}
.detail-left {
border-right: 1px dashed var(--border);
}
.detail-section {
padding: 24px 28px;
border-bottom: 1px dashed var(--border);
}
.edit-form {
display: flex;
flex-direction: column;
gap: 14px;
margin-top: 12px;
}
.field-hint {
font-size: 11px;
color: var(--text-faint);
margin: 6px 0 0;
line-height: 1.4;
}
.field-hint--warn {
color: var(--ember);
border-left: 2px solid var(--ember);
padding: 4px 0 4px 8px;
}
.field-hint code {
font-family: "Commit Mono", monospace;
font-size: 10px;
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 8px;
padding-top: 16px;
border-top: 1px dashed var(--border);
}
.meta-list {
display: flex;
flex-direction: column;
gap: 0;
border: 1px dashed var(--border);
margin-top: 12px;
}
.meta-row {
display: flex;
gap: 16px;
padding: 9px 14px;
border-bottom: 1px dashed var(--border);
}
.meta-row:last-child {
border-bottom: none;
}
.meta-row dt {
font-size: 11px;
color: var(--text-faint);
letter-spacing: 0.02em;
min-width: 140px;
flex-shrink: 0;
padding-top: 1px;
}
.meta-row dd {
font-size: 12px;
color: var(--text);
margin: 0;
word-break: break-all;
}
.meta-action {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.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;
}
.link-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.mono {
font-family: "Commit Mono", monospace;
font-size: 11px;
}
/* ---- STATUS ---- */
.status-ok {
color: var(--green);
}
.status-dim {
color: var(--text-faint);
}
.status-error {
color: var(--ember);
}
/* ---- STATES ---- */
.loading-state,
.error-state {
display: flex;
align-items: center;
gap: 12px;
color: var(--text-dim);
font-size: 13px;
padding: 40px 28px;
}
.spinner {
width: 16px;
height: 16px;
border: 2px dashed var(--candle);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ---- ACTIVITY PANEL ---- */
.detail-right {
position: relative;
}
.activity-panel {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
max-height: calc(100vh - 120px);
}
.activity-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 28px 16px;
border-bottom: 1px dashed var(--border);
flex-shrink: 0;
}
.activity-legend {
font-size: 10px;
color: var(--text-faint);
display: flex;
align-items: center;
gap: 6px;
}
.activity-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 32px 28px;
color: var(--text-faint);
font-size: 12px;
}
.activity-empty {
padding: 32px 28px;
color: var(--text-faint);
font-size: 12px;
}
/* Timeline */
.activity-timeline {
overflow-y: auto;
flex: 1;
padding: 16px 0 24px;
}
.al-item {
display: grid;
grid-template-columns: 20px 1fr;
gap: 0 10px;
padding: 0 28px 0 20px;
margin-bottom: 16px;
position: relative;
}
.al-item::before {
content: '';
position: absolute;
left: 27px;
top: 18px;
bottom: -16px;
width: 1px;
border-left: 1px dashed var(--border);
}
.al-item:last-child::before {
display: none;
}
.al-dot {
width: 6px;
height: 6px;
border: 1px dashed var(--border);
background: var(--bg);
flex-shrink: 0;
margin-top: 4px;
align-self: start;
}
.al-admin .al-dot {
border-color: var(--candle-faint);
background: var(--surface);
}
.al-body {
min-width: 0;
}
.al-row {
display: flex;
align-items: flex-start;
gap: 6px;
flex-wrap: wrap;
}
.al-icon {
width: 14px;
height: 14px;
color: var(--text-faint);
flex-shrink: 0;
margin-top: 1px;
}
.al-text {
flex: 1;
min-width: 0;
color: var(--text);
font-size: 12px;
line-height: 1.4;
}
.al-time {
display: block;
color: var(--text-faint);
font-size: 10px;
margin-top: 3px;
letter-spacing: 0.02em;
}
.al-vis-badge {
font-size: 9px;
color: var(--candle);
border: 1px dashed var(--candle-faint);
padding: 1px 5px;
flex-shrink: 0;
letter-spacing: 0.04em;
}
.al-load-more {
display: flex;
justify-content: center;
padding: 8px 28px 0;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 1024px) {
.detail-body {
grid-template-columns: 1fr;
}
.detail-left {
border-right: none;
}
.activity-panel {
position: static;
max-height: none;
border-top: 1px dashed var(--border);
}
}
@media (max-width: 768px) {
.page-header {
padding: 24px 20px 16px;
}
.detail-section {
padding: 20px;
}
.activity-panel-header {
padding: 16px 20px 12px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.al-item {
padding: 0 20px 0 14px;
}
.meta-row {
flex-direction: column;
gap: 4px;
}
.meta-row dt {
min-width: unset;
}
}
</style>