pending_payment now grants the same RSVP/peer-support capabilities as active,
and status banner/label copy is rewritten to be non-threatening ("Setting up
payment", "Paused", "Closed"). Aligns member-facing copy across the account
page with the capability model.
566 lines
15 KiB
Vue
566 lines
15 KiB
Vue
<template>
|
|
<PageShell>
|
|
<ClientOnly>
|
|
<!-- Unauthenticated -->
|
|
<div v-if="!memberData" class="loading">
|
|
<p>Please sign in to access your account settings.</p>
|
|
<button
|
|
class="btn btn-primary"
|
|
@click="openLoginModal({ title: 'Sign in to manage your account' })"
|
|
>
|
|
Sign In
|
|
</button>
|
|
</div>
|
|
|
|
<template v-else>
|
|
<!-- PAGE HEADER -->
|
|
<PageHeader
|
|
title="Account Settings"
|
|
subtitle="Manage your membership and billing"
|
|
/>
|
|
|
|
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
|
|
<ColumnsLayout cols="events-sidebar">
|
|
<ColumnsLayout cols="2">
|
|
<!-- LEFT COLUMN: Membership Status & Email -->
|
|
<template #left>
|
|
<PageSection>
|
|
<div class="section-label">Current Membership</div>
|
|
|
|
<div class="membership-card">
|
|
<div class="membership-row">
|
|
<span class="membership-k">Status</span>
|
|
<span class="membership-v status-v">
|
|
<span
|
|
class="status-dot"
|
|
:class="memberData.status || 'active'"
|
|
/>
|
|
<span>{{
|
|
formatStatus(memberData.status || "active")
|
|
}}</span>
|
|
</span>
|
|
</div>
|
|
<div class="membership-row">
|
|
<span class="membership-k">Circle</span>
|
|
<span
|
|
class="membership-v"
|
|
:style="{
|
|
color: `var(--c-${memberData.circle || 'community'})`,
|
|
}"
|
|
>
|
|
{{
|
|
memberData.circle
|
|
? capitalise(memberData.circle)
|
|
: "Community"
|
|
}}
|
|
</span>
|
|
</div>
|
|
<div class="membership-row">
|
|
<span class="membership-k">Contribution</span>
|
|
<span class="membership-v"
|
|
>${{ memberData.contributionTier || 0 }} / month</span
|
|
>
|
|
</div>
|
|
<div class="membership-row">
|
|
<span class="membership-k">Member since</span>
|
|
<span class="membership-v">{{
|
|
formatMemberSince(memberData.createdAt)
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
<a
|
|
v-if="helcimPortalUrl && memberData.helcimCustomerId"
|
|
:href="helcimPortalUrl"
|
|
target="_blank"
|
|
rel="noopener"
|
|
class="billing-link"
|
|
>
|
|
Manage billing in Helcim →
|
|
</a>
|
|
</PageSection>
|
|
|
|
<PageSection divider="top">
|
|
<div class="section-label">Email</div>
|
|
|
|
<div v-if="!showEmailEdit" class="email-display">
|
|
<span class="email-value">{{ memberData.email }}</span>
|
|
<button class="btn btn-inline" @click="showEmailEdit = true">
|
|
Change
|
|
</button>
|
|
</div>
|
|
|
|
<div v-else class="email-edit">
|
|
<div class="field">
|
|
<label>New email address</label>
|
|
<input
|
|
v-model="newEmail"
|
|
type="email"
|
|
placeholder="you@example.com"
|
|
autofocus
|
|
@keydown.enter="handleUpdateEmail"
|
|
@keydown.escape="cancelEmailEdit"
|
|
>
|
|
</div>
|
|
<div class="email-edit-actions">
|
|
<button
|
|
class="btn btn-primary"
|
|
:disabled="isUpdatingEmail || !newEmail.trim()"
|
|
@click="handleUpdateEmail"
|
|
>
|
|
{{ isUpdatingEmail ? "Saving…" : "Save" }}
|
|
</button>
|
|
<button
|
|
class="btn"
|
|
:disabled="isUpdatingEmail"
|
|
@click="cancelEmailEdit"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="email-hint">
|
|
Used for login magic links and notifications
|
|
</div>
|
|
</PageSection>
|
|
|
|
<PageSection divider="top" class="danger-section">
|
|
<div class="section-label danger">Danger Zone</div>
|
|
<div class="danger-zone">
|
|
<p>
|
|
Cancelling closes your account and ends access to member-only
|
|
spaces, including Slack. If you're cancelling because of a
|
|
money issue, the
|
|
<NuxtLink to="/community-guidelines">Solidarity Fund</NuxtLink>
|
|
and the $0 tier are always available — reach out before you
|
|
go.
|
|
</p>
|
|
<div v-if="showCancelConfirm" class="cancel-confirm">
|
|
<p class="cancel-confirm-prompt">
|
|
Are you sure? This cannot be easily undone.
|
|
</p>
|
|
<div class="cancel-confirm-actions">
|
|
<button
|
|
class="btn btn-danger"
|
|
:disabled="isCancelling"
|
|
@click="confirmCancelMembership"
|
|
>
|
|
{{ isCancelling ? "Cancelling…" : "Yes, Cancel" }}
|
|
</button>
|
|
<button class="btn" @click="showCancelConfirm = false">
|
|
Nevermind
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<button
|
|
v-else
|
|
class="btn btn-danger"
|
|
:disabled="isCancelling"
|
|
@click="handleCancelMembership"
|
|
>
|
|
Cancel Membership
|
|
</button>
|
|
</div>
|
|
</PageSection>
|
|
</template>
|
|
|
|
<!-- RIGHT COLUMN: Change Contribution & Circle -->
|
|
<template #right>
|
|
<PageSection>
|
|
<div class="section-label">Change Contribution</div>
|
|
|
|
<TierPicker v-model="selectedTier" :tiers="tiers" />
|
|
<div class="tier-hint">
|
|
Changes take effect on your next billing cycle
|
|
</div>
|
|
<button
|
|
class="btn btn-primary btn-section"
|
|
:disabled="
|
|
selectedTier === Number(memberData.contributionTier || 0) ||
|
|
isUpdating
|
|
"
|
|
@click="handleUpdateTier"
|
|
>
|
|
{{ isUpdating ? "Updating…" : "Update Contribution" }}
|
|
</button>
|
|
</PageSection>
|
|
|
|
<PageSection divider="top">
|
|
<div class="section-label">Change Circle</div>
|
|
|
|
<CirclePicker
|
|
v-model="selectedCircle"
|
|
:saved-value="memberData.circle"
|
|
:circles="circleOptions"
|
|
/>
|
|
<button
|
|
class="btn btn-primary btn-section"
|
|
:disabled="selectedCircle === memberData.circle || isUpdating"
|
|
@click="handleUpdateCircle"
|
|
>
|
|
{{ isUpdating ? "Updating…" : "Update Circle" }}
|
|
</button>
|
|
</PageSection>
|
|
</template>
|
|
</ColumnsLayout>
|
|
</ColumnsLayout>
|
|
</template>
|
|
</ClientOnly>
|
|
</PageShell>
|
|
</template>
|
|
|
|
<script setup>
|
|
definePageMeta({
|
|
middleware: "auth",
|
|
});
|
|
|
|
const { memberData, checkMemberStatus } = useAuth();
|
|
const { openLoginModal } = useLoginModal();
|
|
const toast = useToast();
|
|
const helcimPortalUrl = useRuntimeConfig().public.helcimPortalUrl || '';
|
|
|
|
const selectedTier = ref(0);
|
|
const selectedCircle = ref("");
|
|
const isUpdating = ref(false);
|
|
const isCancelling = ref(false);
|
|
|
|
// Email edit state
|
|
const showEmailEdit = ref(false);
|
|
const newEmail = ref("");
|
|
const isUpdatingEmail = ref(false);
|
|
|
|
const tiers = [
|
|
{ amount: 0, display: "$0", label: "I need support right now" },
|
|
{ amount: 5, display: "$5", label: "I can contribute" },
|
|
{ amount: 15, display: "$15", label: "I can sustain the community" },
|
|
{ amount: 30, display: "$30", label: "I can support others too" },
|
|
{ amount: 50, display: "$50", label: "I want to sponsor multiple members" },
|
|
];
|
|
|
|
const circleOptions = [
|
|
{
|
|
value: "community",
|
|
label: "Community",
|
|
description: "Exploring cooperative ideas",
|
|
},
|
|
{
|
|
value: "founder",
|
|
label: "Founder",
|
|
description: "Building a cooperative studio",
|
|
},
|
|
{
|
|
value: "practitioner",
|
|
label: "Practitioner",
|
|
description: "Experienced in cooperative practice",
|
|
},
|
|
];
|
|
|
|
const STATUS_LABELS = {
|
|
active: "Active",
|
|
pending_payment: "Setting up payment",
|
|
suspended: "Paused",
|
|
cancelled: "Closed",
|
|
};
|
|
|
|
const formatStatus = (s) => STATUS_LABELS[s] || s;
|
|
|
|
const capitalise = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
|
|
|
// Initialize from member data
|
|
watchEffect(() => {
|
|
if (memberData.value) {
|
|
selectedTier.value = Number(memberData.value.contributionTier || 0);
|
|
selectedCircle.value = memberData.value.circle || "community";
|
|
}
|
|
});
|
|
|
|
const formatMemberSince = (dateStr) => {
|
|
if (!dateStr) return "";
|
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
|
month: "long",
|
|
year: "numeric",
|
|
});
|
|
};
|
|
|
|
const handleUpdateTier = async () => {
|
|
isUpdating.value = true;
|
|
try {
|
|
await $fetch("/api/members/update-contribution", {
|
|
method: "POST",
|
|
body: { contributionTier: String(selectedTier.value) },
|
|
});
|
|
await checkMemberStatus();
|
|
toast.add({ title: "Contribution updated", color: "success" });
|
|
} catch (err) {
|
|
// Paid upgrade without a saved card — route to payment setup instead of erroring.
|
|
if (err.data?.data?.requiresPaymentSetup) {
|
|
await navigateTo(
|
|
`/member/payment-setup?tier=${selectedTier.value}&circle=${
|
|
selectedCircle.value || memberData.value?.circle || 'community'
|
|
}`,
|
|
);
|
|
return;
|
|
}
|
|
selectedTier.value = Number(memberData.value?.contributionTier || 0);
|
|
toast.add({
|
|
title: "Update failed",
|
|
description: err.data?.statusMessage || "Please try again.",
|
|
color: "error",
|
|
});
|
|
} finally {
|
|
isUpdating.value = false;
|
|
}
|
|
};
|
|
|
|
const handleUpdateCircle = async () => {
|
|
isUpdating.value = true;
|
|
try {
|
|
await $fetch("/api/members/update-circle", {
|
|
method: "POST",
|
|
body: { circle: selectedCircle.value },
|
|
});
|
|
await checkMemberStatus();
|
|
toast.add({ title: "Circle updated", color: "success" });
|
|
} catch (err) {
|
|
selectedCircle.value = memberData.value?.circle || "community";
|
|
toast.add({
|
|
title: "Update failed",
|
|
description: err.data?.statusMessage || "Please try again.",
|
|
color: "error",
|
|
});
|
|
} finally {
|
|
isUpdating.value = false;
|
|
}
|
|
};
|
|
|
|
const cancelEmailEdit = () => {
|
|
showEmailEdit.value = false;
|
|
newEmail.value = "";
|
|
};
|
|
|
|
const handleUpdateEmail = async () => {
|
|
const trimmed = newEmail.value.trim();
|
|
if (!trimmed) return;
|
|
isUpdatingEmail.value = true;
|
|
try {
|
|
await $fetch("/api/members/update-email", {
|
|
method: "POST",
|
|
body: { email: trimmed },
|
|
});
|
|
await checkMemberStatus();
|
|
cancelEmailEdit();
|
|
toast.add({ title: "Email updated", color: "success" });
|
|
} catch (err) {
|
|
toast.add({
|
|
title: "Update failed",
|
|
description: err.data?.statusMessage || "Please try again.",
|
|
color: "error",
|
|
});
|
|
} finally {
|
|
isUpdatingEmail.value = false;
|
|
}
|
|
};
|
|
|
|
const showCancelConfirm = ref(false);
|
|
|
|
const handleCancelMembership = () => {
|
|
showCancelConfirm.value = true;
|
|
};
|
|
|
|
const confirmCancelMembership = async () => {
|
|
showCancelConfirm.value = false;
|
|
isCancelling.value = true;
|
|
try {
|
|
const result = await $fetch("/api/members/cancel-subscription", {
|
|
method: "POST",
|
|
});
|
|
await checkMemberStatus();
|
|
if (result.message === "No active subscription to cancel") {
|
|
toast.add({
|
|
title: "No active subscription",
|
|
description: "You are on the free tier — nothing to cancel.",
|
|
color: "neutral",
|
|
});
|
|
} else {
|
|
toast.add({ title: "Membership cancelled", color: "warning" });
|
|
}
|
|
} catch (err) {
|
|
toast.add({
|
|
title: "Cancellation failed",
|
|
description: err.data?.statusMessage || "Please try again.",
|
|
color: "error",
|
|
});
|
|
} finally {
|
|
isCancelling.value = false;
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.loading {
|
|
padding: 48px 32px;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
/* ---- MEMBERSHIP CARD ---- */
|
|
.membership-card {
|
|
border: 1px dashed var(--border);
|
|
padding: 0;
|
|
margin-bottom: 12px;
|
|
}
|
|
.membership-row {
|
|
display: grid;
|
|
grid-template-columns: 120px 1fr;
|
|
gap: 0 12px;
|
|
align-items: center;
|
|
padding: 10px 20px;
|
|
font-size: 12px;
|
|
border-bottom: 1px dashed var(--border);
|
|
}
|
|
.membership-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.membership-k {
|
|
color: var(--text-faint);
|
|
}
|
|
.membership-v {
|
|
color: var(--text);
|
|
}
|
|
|
|
/* Status dot — flex row so gap is exact, no whitespace-node gap */
|
|
.status-v {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
}
|
|
.status-dot {
|
|
display: inline-block;
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
.status-dot.active {
|
|
background: var(--green);
|
|
}
|
|
.status-dot.suspended {
|
|
background: var(--ember);
|
|
}
|
|
.status-dot.cancelled {
|
|
background: var(--text-faint);
|
|
}
|
|
.status-dot.pending_payment {
|
|
background: var(--candle);
|
|
}
|
|
|
|
/* ---- EMAIL ---- */
|
|
.email-display {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 10px;
|
|
font-size: 13px;
|
|
margin-bottom: 6px;
|
|
}
|
|
.email-value {
|
|
color: var(--text);
|
|
}
|
|
.btn-inline {
|
|
font-size: 10px;
|
|
letter-spacing: 0.06em;
|
|
padding: 2px 8px;
|
|
border-style: dashed;
|
|
line-height: 1.6;
|
|
}
|
|
.email-edit {
|
|
margin-bottom: 6px;
|
|
}
|
|
.email-edit .field {
|
|
margin-bottom: 8px;
|
|
}
|
|
.email-edit .field label {
|
|
font-size: 10px;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: var(--text-faint);
|
|
margin-bottom: 3px;
|
|
display: block;
|
|
}
|
|
.email-edit .field input {
|
|
width: 100%;
|
|
padding: 5px 8px;
|
|
font-family: "Commit Mono", monospace;
|
|
font-size: 13px;
|
|
background: var(--input-bg);
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
outline: none;
|
|
}
|
|
.email-edit .field input:focus {
|
|
border-color: var(--candle);
|
|
}
|
|
.email-edit-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
.email-hint {
|
|
font-size: 11px;
|
|
color: var(--text-faint);
|
|
}
|
|
|
|
/* ---- DANGER ZONE ---- */
|
|
.danger-section {
|
|
background: var(--ember-bg);
|
|
}
|
|
|
|
.danger-section .section-label.danger {
|
|
color: var(--ember);
|
|
}
|
|
.danger-section .danger-zone p {
|
|
color: var(--text-dim);
|
|
font-size: 12px;
|
|
line-height: 1.7;
|
|
margin-bottom: 12px;
|
|
max-width: 400px;
|
|
}
|
|
|
|
/* ---- CANCEL CONFIRM ---- */
|
|
.cancel-confirm {
|
|
border: 1px dashed var(--ember);
|
|
padding: 14px 16px;
|
|
margin-bottom: 0;
|
|
}
|
|
.cancel-confirm-prompt {
|
|
font-size: 12px;
|
|
color: var(--ember);
|
|
margin-bottom: 10px;
|
|
}
|
|
.cancel-confirm-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
/* ---- TIER HINT ---- */
|
|
.tier-hint {
|
|
font-size: 11px;
|
|
color: var(--text-faint);
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.btn-section {
|
|
width: 100%;
|
|
text-align: center;
|
|
}
|
|
|
|
.billing-link {
|
|
display: inline-block;
|
|
margin-top: 10px;
|
|
font-size: 12px;
|
|
color: var(--candle);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.billing-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
</style>
|