feat(member): pending_payment retains access, soften status copy

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.
This commit is contained in:
Jennie Robinson Faber 2026-04-18 17:06:22 +01:00
parent 15329e3e84
commit 37a58cb0eb
3 changed files with 43 additions and 43 deletions

View file

@ -12,16 +12,16 @@ export const MEMBER_STATUSES = {
export const MEMBER_STATUS_CONFIG = { export const MEMBER_STATUS_CONFIG = {
pending_payment: { pending_payment: {
label: "Payment Pending", label: "Setting up payment",
color: "orange", color: "orange",
bgColor: "bg-orange-500/10", bgColor: "bg-orange-500/10",
borderColor: "border-orange-500/30", borderColor: "border-orange-500/30",
textColor: "text-orange-300", textColor: "text-orange-300",
icon: "heroicons:exclamation-triangle", icon: "heroicons:exclamation-triangle",
severity: "warning", severity: "warning",
canRSVP: false, canRSVP: true,
canAccessMembers: true, canAccessMembers: true,
canPeerSupport: false, canPeerSupport: true,
}, },
active: { active: {
label: "Active Member", label: "Active Member",
@ -126,24 +126,21 @@ export const useMemberStatus = () => {
// Get banner message based on status // Get banner message based on status
const getBannerMessage = () => { const getBannerMessage = () => {
if (isPendingPayment.value) { if (isPendingPayment.value) {
return "Your membership is pending payment. Please complete your payment to unlock full features."; return "Your payment setup isn't finished yet. Your membership and access aren't affected — finish whenever you're ready, or reach out if there's a snag.";
} }
if (isSuspended.value) { if (isSuspended.value) {
return "Your membership has been suspended. Please contact support to reactivate your account."; return "Your account is paused while we work through a community issue. We'll be in touch.";
} }
if (isCancelled.value) { if (isCancelled.value) {
return "Your membership has been cancelled. Would you like to reactivate?"; return "Your account is closed. Reach out if you'd like to come back.";
} }
return null; return null;
}; };
// Get RSVP restriction message // Get RSVP restriction message
const getRSVPMessage = () => { const getRSVPMessage = () => {
if (isPendingPayment.value) {
return "Complete your payment to register for events";
}
if (isSuspended.value || isCancelled.value) { if (isSuspended.value || isCancelled.value) {
return "Your membership status prevents RSVP. Please reactivate your account."; return "Your account isn't active right now. Reach out if you have questions.";
} }
return null; return null;
}; };

View file

@ -34,7 +34,7 @@
<span <span
class="status-dot" class="status-dot"
:class="memberData.status || 'active'" :class="memberData.status || 'active'"
></span> />
<span>{{ <span>{{
formatStatus(memberData.status || "active") formatStatus(memberData.status || "active")
}}</span> }}</span>
@ -93,26 +93,26 @@
<div class="field"> <div class="field">
<label>New email address</label> <label>New email address</label>
<input <input
type="email"
v-model="newEmail" v-model="newEmail"
type="email"
placeholder="you@example.com" placeholder="you@example.com"
autofocus
@keydown.enter="handleUpdateEmail" @keydown.enter="handleUpdateEmail"
@keydown.escape="cancelEmailEdit" @keydown.escape="cancelEmailEdit"
autofocus >
/>
</div> </div>
<div class="email-edit-actions"> <div class="email-edit-actions">
<button <button
class="btn btn-primary" class="btn btn-primary"
@click="handleUpdateEmail"
:disabled="isUpdatingEmail || !newEmail.trim()" :disabled="isUpdatingEmail || !newEmail.trim()"
@click="handleUpdateEmail"
> >
{{ isUpdatingEmail ? "Saving…" : "Save" }} {{ isUpdatingEmail ? "Saving…" : "Save" }}
</button> </button>
<button <button
class="btn" class="btn"
@click="cancelEmailEdit"
:disabled="isUpdatingEmail" :disabled="isUpdatingEmail"
@click="cancelEmailEdit"
> >
Cancel Cancel
</button> </button>
@ -128,9 +128,12 @@
<div class="section-label danger">Danger Zone</div> <div class="section-label danger">Danger Zone</div>
<div class="danger-zone"> <div class="danger-zone">
<p> <p>
Cancelling your membership will immediately revoke access to Cancelling closes your account and ends access to member-only
member-only resources, events, and the Slack workspace. spaces, including Slack. If you're cancelling because of a
<strong>This action cannot be easily undone.</strong> money issue, the
<NuxtLink to="/community-guidelines">Solidarity Fund</NuxtLink>
and the $0 tier are always available reach out before you
go.
</p> </p>
<div v-if="showCancelConfirm" class="cancel-confirm"> <div v-if="showCancelConfirm" class="cancel-confirm">
<p class="cancel-confirm-prompt"> <p class="cancel-confirm-prompt">
@ -139,8 +142,8 @@
<div class="cancel-confirm-actions"> <div class="cancel-confirm-actions">
<button <button
class="btn btn-danger" class="btn btn-danger"
@click="confirmCancelMembership"
:disabled="isCancelling" :disabled="isCancelling"
@click="confirmCancelMembership"
> >
{{ isCancelling ? "Cancelling…" : "Yes, Cancel" }} {{ isCancelling ? "Cancelling…" : "Yes, Cancel" }}
</button> </button>
@ -152,8 +155,8 @@
<button <button
v-else v-else
class="btn btn-danger" class="btn btn-danger"
@click="handleCancelMembership"
:disabled="isCancelling" :disabled="isCancelling"
@click="handleCancelMembership"
> >
Cancel Membership Cancel Membership
</button> </button>
@ -172,11 +175,11 @@
</div> </div>
<button <button
class="btn btn-primary btn-section" class="btn btn-primary btn-section"
@click="handleUpdateTier"
:disabled=" :disabled="
selectedTier === Number(memberData.contributionTier || 0) || selectedTier === Number(memberData.contributionTier || 0) ||
isUpdating isUpdating
" "
@click="handleUpdateTier"
> >
{{ isUpdating ? "Updating…" : "Update Contribution" }} {{ isUpdating ? "Updating…" : "Update Contribution" }}
</button> </button>
@ -192,8 +195,8 @@
/> />
<button <button
class="btn btn-primary btn-section" class="btn btn-primary btn-section"
@click="handleUpdateCircle"
:disabled="selectedCircle === memberData.circle || isUpdating" :disabled="selectedCircle === memberData.circle || isUpdating"
@click="handleUpdateCircle"
> >
{{ isUpdating ? "Updating…" : "Update Circle" }} {{ isUpdating ? "Updating…" : "Update Circle" }}
</button> </button>
@ -254,9 +257,9 @@ const circleOptions = [
const STATUS_LABELS = { const STATUS_LABELS = {
active: "Active", active: "Active",
pending_payment: "Pending", pending_payment: "Setting up payment",
suspended: "Suspended", suspended: "Paused",
cancelled: "Cancelled", cancelled: "Closed",
}; };
const formatStatus = (s) => STATUS_LABELS[s] || s; const formatStatus = (s) => STATUS_LABELS[s] || s;

View file

@ -61,11 +61,11 @@ describe("MEMBER_STATUS_CONFIG", () => {
expect(cfg.canPeerSupport).toBe(true); expect(cfg.canPeerSupport).toBe(true);
}); });
it("pending_payment can access members but not RSVP or peer support", () => { it("pending_payment has full permissions (payment is decoupled from access)", () => {
const cfg = MEMBER_STATUS_CONFIG.pending_payment; const cfg = MEMBER_STATUS_CONFIG.pending_payment;
expect(cfg.canRSVP).toBe(false); expect(cfg.canRSVP).toBe(true);
expect(cfg.canAccessMembers).toBe(true); expect(cfg.canAccessMembers).toBe(true);
expect(cfg.canPeerSupport).toBe(false); expect(cfg.canPeerSupport).toBe(true);
}); });
it("suspended has all permissions false", () => { it("suspended has all permissions false", () => {
@ -122,10 +122,10 @@ describe("useMemberStatus composable", () => {
expect(canRSVP.value).toBe(true); expect(canRSVP.value).toBe(true);
}); });
it("canRSVP is false when pending_payment", () => { it("canRSVP is true when pending_payment (decoupled from payment)", () => {
memberData.value = { status: "pending_payment" }; memberData.value = { status: "pending_payment" };
const { canRSVP } = useMemberStatus(); const { canRSVP } = useMemberStatus();
expect(canRSVP.value).toBe(false); expect(canRSVP.value).toBe(true);
}); });
it("canAccessMembers is true for active and pending_payment", () => { it("canAccessMembers is true for active and pending_payment", () => {
@ -182,22 +182,22 @@ describe("useMemberStatus composable", () => {
}); });
describe("getBannerMessage", () => { describe("getBannerMessage", () => {
it("returns payment message for pending_payment", () => { it("returns payment-setup message for pending_payment", () => {
memberData.value = { status: "pending_payment" }; memberData.value = { status: "pending_payment" };
const { getBannerMessage } = useMemberStatus(); const { getBannerMessage } = useMemberStatus();
expect(getBannerMessage()).toContain("pending payment"); expect(getBannerMessage()).toContain("payment setup");
}); });
it("returns suspended message for suspended", () => { it("returns paused message for suspended", () => {
memberData.value = { status: "suspended" }; memberData.value = { status: "suspended" };
const { getBannerMessage } = useMemberStatus(); const { getBannerMessage } = useMemberStatus();
expect(getBannerMessage()).toContain("suspended"); expect(getBannerMessage()).toContain("paused");
}); });
it("returns cancelled message for cancelled", () => { it("returns closed message for cancelled", () => {
memberData.value = { status: "cancelled" }; memberData.value = { status: "cancelled" };
const { getBannerMessage } = useMemberStatus(); const { getBannerMessage } = useMemberStatus();
expect(getBannerMessage()).toContain("cancelled"); expect(getBannerMessage()).toContain("closed");
}); });
it("returns null for active", () => { it("returns null for active", () => {
@ -208,22 +208,22 @@ describe("useMemberStatus composable", () => {
}); });
describe("getRSVPMessage", () => { describe("getRSVPMessage", () => {
it("returns payment message for pending_payment", () => { it("returns null for pending_payment (decoupled from RSVP)", () => {
memberData.value = { status: "pending_payment" }; memberData.value = { status: "pending_payment" };
const { getRSVPMessage } = useMemberStatus(); const { getRSVPMessage } = useMemberStatus();
expect(getRSVPMessage()).toContain("payment"); expect(getRSVPMessage()).toBeNull();
}); });
it("returns restriction message for suspended", () => { it("returns inactive-account message for suspended", () => {
memberData.value = { status: "suspended" }; memberData.value = { status: "suspended" };
const { getRSVPMessage } = useMemberStatus(); const { getRSVPMessage } = useMemberStatus();
expect(getRSVPMessage()).toContain("reactivate"); expect(getRSVPMessage()).toContain("isn't active");
}); });
it("returns restriction message for cancelled", () => { it("returns inactive-account message for cancelled", () => {
memberData.value = { status: "cancelled" }; memberData.value = { status: "cancelled" };
const { getRSVPMessage } = useMemberStatus(); const { getRSVPMessage } = useMemberStatus();
expect(getRSVPMessage()).toContain("reactivate"); expect(getRSVPMessage()).toContain("isn't active");
}); });
it("returns null for active", () => { it("returns null for active", () => {