402 lines
9.2 KiB
Vue
402 lines
9.2 KiB
Vue
<template>
|
|
<div class="admin-member-detail">
|
|
<div class="page-header">
|
|
<div class="header-row">
|
|
<div>
|
|
<NuxtLink to="/admin/members" class="back-link">← Members</NuxtLink>
|
|
<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-actions">
|
|
<span class="badge" :class="member.circle">{{ member.circle }}</span>
|
|
<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">
|
|
<!-- 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 tier ($/mo)</label>
|
|
<select v-model="form.contributionTier">
|
|
<option value="0">$0</option>
|
|
<option value="5">$5</option>
|
|
<option value="15">$15</option>
|
|
<option value="30">$30</option>
|
|
<option value="50">$50</option>
|
|
</select>
|
|
</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 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 :class="member.slackInvited ? 'status-ok' : 'status-dim'">
|
|
{{ member.slackInvited ? "Invited" : "Pending" }}
|
|
</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>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
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: "",
|
|
contributionTier: "",
|
|
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.contributionTier = String(m.contributionTier);
|
|
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,
|
|
contributionTier: form.contributionTier,
|
|
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: "green" });
|
|
} catch (err) {
|
|
toast.add({
|
|
title: "Failed to update member",
|
|
description: err.data?.statusMessage || err.message,
|
|
color: "red",
|
|
});
|
|
} 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";
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.admin-member-detail {
|
|
max-width: 640px;
|
|
padding: 32px 40px 60px;
|
|
}
|
|
|
|
.page-header {
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.header-row {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
}
|
|
|
|
.back-link {
|
|
display: inline-block;
|
|
font-size: 11px;
|
|
color: var(--text-faint);
|
|
text-decoration: none;
|
|
margin-bottom: 8px;
|
|
|
|
letter-spacing: 0.02em;
|
|
}
|
|
|
|
.back-link:hover {
|
|
color: var(--candle);
|
|
}
|
|
|
|
.page-header h1 {
|
|
font-family: "Brygada 1918", serif;
|
|
font-size: 24px;
|
|
font-weight: 600;
|
|
color: var(--text-bright);
|
|
margin: 0 0 4px;
|
|
}
|
|
|
|
.member-email {
|
|
font-size: 12px;
|
|
color: var(--text-faint);
|
|
margin: 0;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
padding-top: 6px;
|
|
}
|
|
|
|
.status-badge {
|
|
font-size: 11px;
|
|
font-family: "Commit Mono", monospace;
|
|
padding: 2px 8px;
|
|
border: 1px dashed var(--border);
|
|
}
|
|
|
|
.detail-section {
|
|
margin-bottom: 40px;
|
|
}
|
|
|
|
.edit-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.form-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.meta-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0;
|
|
border: 1px dashed var(--border);
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.meta-row {
|
|
display: flex;
|
|
gap: 24px;
|
|
padding: 10px 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;
|
|
}
|
|
|
|
.mono {
|
|
font-family: "Commit Mono", monospace;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.status-ok {
|
|
color: var(--c-founder);
|
|
}
|
|
|
|
.status-dim {
|
|
color: var(--text-faint);
|
|
}
|
|
|
|
.status-error {
|
|
color: var(--ember);
|
|
}
|
|
|
|
.loading-state,
|
|
.error-state {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
color: var(--text-dim);
|
|
font-size: 13px;
|
|
padding: 40px 0;
|
|
}
|
|
|
|
.spinner {
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 2px solid var(--border);
|
|
border-top-color: var(--candle);
|
|
border-radius: 50%;
|
|
animation: spin 0.7s linear infinite;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.admin-member-detail {
|
|
padding: 20px 16px 40px;
|
|
}
|
|
|
|
.header-row {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.meta-row {
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.meta-row dt {
|
|
min-width: unset;
|
|
}
|
|
}
|
|
</style>
|