Persist nextBillingDate on subscription create/update; unset on cancel or downgrade to free. Account page displays the cached date and lazily refreshes from Helcim when the cached value is within 24h of now (or missing).
927 lines
25 KiB
Vue
927 lines
25 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">{{ currentContributionLabel }}</span>
|
|
</div>
|
|
<div v-if="nextPaymentDate" class="membership-row">
|
|
<span class="membership-k">Next payment</span>
|
|
<span class="membership-v">{{ formatNextPaymentDate(nextPaymentDate) }}</span>
|
|
</div>
|
|
<div class="membership-row">
|
|
<span class="membership-k">Member since</span>
|
|
<span class="membership-v">{{
|
|
formatMemberSince(memberData.createdAt)
|
|
}}</span>
|
|
</div>
|
|
</div>
|
|
</PageSection>
|
|
|
|
<!-- PAYMENT HISTORY (only when a Helcim customer exists) -->
|
|
<PageSection
|
|
v-if="memberData.helcimCustomerId"
|
|
divider="top"
|
|
>
|
|
<div class="section-label">Payment history</div>
|
|
|
|
<div v-if="paymentHistoryLoading" class="history-card">
|
|
<div class="history-row history-state">Loading…</div>
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="paymentHistoryError"
|
|
class="history-card"
|
|
>
|
|
<div class="history-row history-state">
|
|
Payment history temporarily unavailable. Try again in a few minutes.
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="paymentHistory.length === 0"
|
|
class="history-card"
|
|
>
|
|
<div class="history-row history-state">
|
|
No payments yet. Your first charge will appear here after your next billing cycle.
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="history-card">
|
|
<div
|
|
v-for="txn in paymentHistory"
|
|
:key="txn.id"
|
|
class="history-row"
|
|
>
|
|
<span class="history-date">{{ formatTxnDate(txn.date) }}</span>
|
|
<span class="history-amount">{{ formatTxnAmount(txn.amount, txn.currency) }}</span>
|
|
<span
|
|
class="history-status"
|
|
:class="`status-${txn.status}`"
|
|
>{{ formatTxnStatus(txn.status) }}</span>
|
|
</div>
|
|
</div>
|
|
</PageSection>
|
|
|
|
<!-- CHANGE CARD (only for active subscriptions) -->
|
|
<PageSection
|
|
v-if="canChangeCard"
|
|
divider="top"
|
|
>
|
|
<div class="section-label">Change card</div>
|
|
<p class="change-card-hint">
|
|
Replace the card on file. Future charges will use the new card.
|
|
</p>
|
|
<button
|
|
class="btn btn-primary btn-section"
|
|
:disabled="isChangingCard"
|
|
@click="handleChangeCard"
|
|
>
|
|
{{ changeCardButtonLabel }}
|
|
</button>
|
|
</PageSection>
|
|
|
|
<!-- ADVANCED BILLING LINK (escape hatch) -->
|
|
<PageSection
|
|
v-if="helcimPortalUrl && memberData.helcimCustomerId"
|
|
divider="top"
|
|
>
|
|
<a
|
|
:href="helcimPortalUrl"
|
|
target="_blank"
|
|
rel="noopener"
|
|
class="billing-link"
|
|
>
|
|
Advanced 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>
|
|
import { getTierAmount, getContributionTierByValue } from '~/config/contributions';
|
|
|
|
definePageMeta({
|
|
middleware: "auth",
|
|
});
|
|
|
|
const { memberData, checkMemberStatus } = useAuth();
|
|
const { openLoginModal } = useLoginModal();
|
|
const { initializeHelcimPay, verifyPayment, cleanup: cleanupHelcimPay } = useHelcimPay();
|
|
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);
|
|
|
|
// Payment history state
|
|
const paymentHistory = ref([]);
|
|
const paymentHistoryLoading = ref(false);
|
|
const paymentHistoryError = ref(false);
|
|
const paymentHistoryLoaded = ref(false);
|
|
|
|
// Next payment (refreshed lazily from Helcim when cached date is stale)
|
|
const refreshedNextBillingDate = ref(null);
|
|
const nextBillingRefreshed = ref(false);
|
|
|
|
const nextPaymentDate = computed(() => {
|
|
const m = memberData.value;
|
|
if (!m) return null;
|
|
if (m.status !== 'active') return null;
|
|
if (String(m.contributionTier || '0') === '0') return null;
|
|
return refreshedNextBillingDate.value || m.nextBillingDate || null;
|
|
});
|
|
|
|
// Change-card state
|
|
const isChangingCard = ref(false);
|
|
const changeCardButtonLabel = ref("Change card");
|
|
|
|
const canChangeCard = computed(() => {
|
|
const m = memberData.value;
|
|
if (!m) return false;
|
|
if (!m.helcimCustomerId) return false;
|
|
if (!["active", "pending_payment"].includes(m.status)) return false;
|
|
// $0 tier has no subscription to attach a card to
|
|
if (String(m.contributionTier || "0") === "0") return false;
|
|
return true;
|
|
});
|
|
|
|
const BASE_TIERS = [
|
|
{ amount: 0, label: "I need support right now" },
|
|
{ amount: 5, label: "I can contribute" },
|
|
{ amount: 15, label: "I can sustain the community" },
|
|
{ amount: 30, label: "I can support others too" },
|
|
{ amount: 50, label: "I want to sponsor multiple members" },
|
|
];
|
|
|
|
const tiers = computed(() => {
|
|
const cadence = memberData.value?.billingCadence || 'monthly';
|
|
return BASE_TIERS.map((t) => {
|
|
if (t.amount === 0) return { ...t, display: '$0' };
|
|
const suffix = cadence === 'annual' ? '/yr' : '/mo';
|
|
return {
|
|
...t,
|
|
display: `$${getTierAmount(t, cadence)}${suffix}`,
|
|
subtitle: cadence === 'annual' ? `$${t.amount}/mo tier` : null,
|
|
};
|
|
});
|
|
});
|
|
|
|
const currentContributionLabel = computed(() => {
|
|
const tier = getContributionTierByValue(String(memberData.value?.contributionTier || '0'));
|
|
const cadence = memberData.value?.billingCadence || 'monthly';
|
|
if (!tier || tier.amount === 0) return '$0';
|
|
const amount = getTierAmount(tier, cadence);
|
|
return cadence === 'annual' ? `$${amount} / year` : `$${amount} / month`;
|
|
});
|
|
|
|
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 formatNextPaymentDate = (dateStr) => {
|
|
if (!dateStr) return "";
|
|
const d = new Date(dateStr);
|
|
if (Number.isNaN(d.getTime())) return "";
|
|
return d.toLocaleDateString("en-US", {
|
|
month: "long",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
});
|
|
};
|
|
|
|
const STALE_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
|
|
const isNextBillingStale = (dateStr) => {
|
|
if (!dateStr) return true;
|
|
const d = new Date(dateStr);
|
|
if (Number.isNaN(d.getTime())) return true;
|
|
return d.getTime() - Date.now() < STALE_WINDOW_MS;
|
|
};
|
|
|
|
const refreshNextBillingIfStale = async () => {
|
|
if (nextBillingRefreshed.value) return;
|
|
const m = memberData.value;
|
|
if (!m) return;
|
|
if (m.status !== 'active') return;
|
|
if (String(m.contributionTier || '0') === '0') return;
|
|
if (!isNextBillingStale(m.nextBillingDate)) return;
|
|
|
|
nextBillingRefreshed.value = true;
|
|
try {
|
|
const response = await $fetch("/api/helcim/subscription");
|
|
const fresh = response?.subscription?.nextBillingDate;
|
|
if (fresh) refreshedNextBillingDate.value = fresh;
|
|
} catch (err) {
|
|
// Silent — fall back to cached value (if any)
|
|
}
|
|
};
|
|
|
|
const handleUpdateTier = async () => {
|
|
isUpdating.value = true;
|
|
try {
|
|
await $fetch("/api/members/update-contribution", {
|
|
method: "POST",
|
|
body: {
|
|
contributionTier: String(selectedTier.value),
|
|
cadence: memberData.value?.billingCadence || 'monthly',
|
|
},
|
|
});
|
|
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;
|
|
}
|
|
};
|
|
|
|
// Payment history
|
|
const loadPaymentHistory = async () => {
|
|
if (paymentHistoryLoaded.value) return;
|
|
if (!memberData.value?.helcimCustomerId) return;
|
|
paymentHistoryLoading.value = true;
|
|
paymentHistoryError.value = false;
|
|
try {
|
|
const response = await $fetch("/api/helcim/payment-history");
|
|
if (response?.error === "unavailable") {
|
|
paymentHistoryError.value = true;
|
|
paymentHistory.value = [];
|
|
} else {
|
|
paymentHistory.value = Array.isArray(response?.transactions)
|
|
? response.transactions
|
|
: [];
|
|
}
|
|
} catch (err) {
|
|
paymentHistoryError.value = true;
|
|
paymentHistory.value = [];
|
|
} finally {
|
|
paymentHistoryLoading.value = false;
|
|
paymentHistoryLoaded.value = true;
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
if (memberData.value?.helcimCustomerId) {
|
|
loadPaymentHistory();
|
|
}
|
|
refreshNextBillingIfStale();
|
|
});
|
|
|
|
watch(
|
|
() => memberData.value?.helcimCustomerId,
|
|
(id) => {
|
|
if (id && !paymentHistoryLoaded.value) {
|
|
loadPaymentHistory();
|
|
}
|
|
},
|
|
);
|
|
|
|
watch(
|
|
() => memberData.value?.status,
|
|
() => {
|
|
refreshNextBillingIfStale();
|
|
},
|
|
);
|
|
|
|
const formatTxnDate = (iso) => {
|
|
if (!iso) return "—";
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return "—";
|
|
return d.toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "2-digit",
|
|
year: "numeric",
|
|
});
|
|
};
|
|
|
|
const formatTxnAmount = (amount, currency) => {
|
|
const num = Number(amount) || 0;
|
|
const cur = currency ? ` ${currency}` : "";
|
|
return `$${num.toFixed(2)}${cur}`;
|
|
};
|
|
|
|
const STATUS_BADGE = {
|
|
paid: "Paid",
|
|
refunded: "Refunded",
|
|
failed: "Failed",
|
|
other: "—",
|
|
};
|
|
|
|
const formatTxnStatus = (s) => STATUS_BADGE[s] || "—";
|
|
|
|
// Change card
|
|
const handleChangeCard = async () => {
|
|
if (isChangingCard.value) return;
|
|
if (!canChangeCard.value) return;
|
|
|
|
isChangingCard.value = true;
|
|
changeCardButtonLabel.value = "Opening…";
|
|
|
|
try {
|
|
// Fetch current customer id + code
|
|
const customerResponse = await $fetch("/api/helcim/customer-code");
|
|
const customerId = customerResponse?.customerId;
|
|
const customerCode = customerResponse?.customerCode;
|
|
if (!customerId || !customerCode) {
|
|
throw new Error("Could not locate customer record");
|
|
}
|
|
|
|
await initializeHelcimPay(customerId, customerCode, 0);
|
|
|
|
let paymentResult;
|
|
try {
|
|
paymentResult = await verifyPayment();
|
|
} catch (cancelOrFailure) {
|
|
// User cancelled or iframe failed — no server call
|
|
return;
|
|
}
|
|
|
|
if (!paymentResult?.success || !paymentResult?.cardToken) {
|
|
return;
|
|
}
|
|
|
|
changeCardButtonLabel.value = "Updating…";
|
|
|
|
try {
|
|
await $fetch("/api/helcim/update-card", {
|
|
method: "POST",
|
|
body: { cardToken: paymentResult.cardToken },
|
|
});
|
|
toast.add({
|
|
title: "Card updated",
|
|
description: "Future charges will use your new card.",
|
|
color: "success",
|
|
});
|
|
} catch (err) {
|
|
console.error("[change-card] update failed", err);
|
|
toast.add({
|
|
title: "Could not update card",
|
|
description: "Please try again.",
|
|
color: "error",
|
|
actions: [
|
|
{
|
|
label: "Retry",
|
|
onClick: () => handleChangeCard(),
|
|
},
|
|
],
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error("[change-card] flow failed", err);
|
|
toast.add({
|
|
title: "Could not update card",
|
|
description: "Please try again.",
|
|
color: "error",
|
|
});
|
|
} finally {
|
|
cleanupHelcimPay();
|
|
isChangingCard.value = false;
|
|
changeCardButtonLabel.value = "Change card";
|
|
}
|
|
};
|
|
|
|
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;
|
|
font-size: 11px;
|
|
letter-spacing: 0.04em;
|
|
color: var(--text-faint);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.billing-link:hover {
|
|
color: var(--candle);
|
|
text-decoration: underline;
|
|
}
|
|
|
|
/* ---- PAYMENT HISTORY ---- */
|
|
.history-card {
|
|
border: 1px dashed var(--border);
|
|
padding: 0;
|
|
margin-bottom: 12px;
|
|
}
|
|
.history-row {
|
|
display: grid;
|
|
grid-template-columns: 120px 1fr auto;
|
|
gap: 0 12px;
|
|
align-items: center;
|
|
padding: 10px 20px;
|
|
font-size: 12px;
|
|
border-bottom: 1px dashed var(--border);
|
|
}
|
|
.history-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.history-date {
|
|
color: var(--text-faint);
|
|
}
|
|
.history-amount {
|
|
color: var(--text);
|
|
}
|
|
.history-status {
|
|
color: var(--text-faint);
|
|
font-size: 11px;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
.history-status.status-failed {
|
|
color: var(--ember);
|
|
}
|
|
.history-state {
|
|
color: var(--text-faint);
|
|
font-style: italic;
|
|
display: block;
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
/* ---- CHANGE CARD ---- */
|
|
.change-card-hint {
|
|
font-size: 11px;
|
|
color: var(--text-faint);
|
|
margin-bottom: 12px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
</style>
|