feat(account): replace tier control with amount input + guidance chips

This commit is contained in:
Jennie Robinson Faber 2026-04-19 19:03:08 +01:00
parent 4d10c4e0a2
commit 5ef0cc845f

View file

@ -236,14 +236,42 @@
<PageSection>
<div class="section-label">Change Contribution</div>
<TierPicker v-model="selectedTier" :tiers="tiers" />
<div class="form-group">
<label class="form-label" for="account-contribution">
{{ cadence === 'annual' ? 'Annual' : 'Monthly' }} Contribution
</label>
<div class="contribution-input-row">
<span class="contribution-currency">$</span>
<input
id="account-contribution"
v-model.number="form.contributionAmount"
type="number"
min="0"
step="1"
inputmode="numeric"
class="contribution-input"
>
</div>
<div class="contribution-presets" role="group" aria-label="Suggested amounts">
<button
v-for="preset in CONTRIBUTION_PRESETS"
:key="preset.amount"
type="button"
class="contribution-preset-chip"
@click="form.contributionAmount = preset.amount"
>
${{ preset.amount }}
</button>
</div>
<p v-if="guidanceLabel" class="contribution-guidance">{{ guidanceLabel }}</p>
</div>
<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) ||
form.contributionAmount === Number(memberData.contributionAmount || 0) ||
isUpdating
"
@click="handleUpdateTier"
@ -277,7 +305,7 @@
</template>
<script setup>
import { getTierAmount, getContributionTierByValue } from '~/config/contributions';
import { CONTRIBUTION_PRESETS, getGuidanceLabel, requiresPayment } from '~/config/contributions';
definePageMeta({
middleware: "auth",
@ -289,7 +317,7 @@ const { initializeHelcimPay, verifyPayment, cleanup: cleanupHelcimPay } = useHel
const toast = useToast();
const helcimPortalUrl = useRuntimeConfig().public.helcimPortalUrl || '';
const selectedTier = ref(0);
const form = reactive({ contributionAmount: 0 });
const selectedCircle = ref("");
const isUpdating = ref(false);
const isCancelling = ref(false);
@ -315,37 +343,19 @@ const canChangeCard = computed(() => {
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;
if (!requiresPayment(Number(m.contributionAmount || 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 cadence = computed(() => memberData.value?.billingCadence || 'monthly');
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 guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount));
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 amount = Number(memberData.value?.contributionAmount || 0);
if (!amount) return '$0';
const displayAmount = cadence.value === 'annual' ? amount * 12 : amount;
return cadence.value === 'annual' ? `$${displayAmount} / year` : `$${displayAmount} / month`;
});
const circleOptions = [
@ -380,7 +390,7 @@ 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);
form.contributionAmount = Number(memberData.value.contributionAmount || 0);
selectedCircle.value = memberData.value.circle || "community";
}
});
@ -399,8 +409,8 @@ const handleUpdateTier = async () => {
await $fetch("/api/members/update-contribution", {
method: "POST",
body: {
contributionTier: String(selectedTier.value),
cadence: memberData.value?.billingCadence || 'monthly',
contributionAmount: form.contributionAmount,
cadence: cadence.value,
},
});
await checkMemberStatus();
@ -409,13 +419,13 @@ const handleUpdateTier = async () => {
// 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=${
`/member/payment-setup?tier=${form.contributionAmount}&circle=${
selectedCircle.value || memberData.value?.circle || 'community'
}`,
);
return;
}
selectedTier.value = Number(memberData.value?.contributionTier || 0);
form.contributionAmount = Number(memberData.value?.contributionAmount || 0);
toast.add({
title: "Update failed",
description: err.data?.statusMessage || "Please try again.",
@ -797,6 +807,52 @@ const confirmCancelMembership = async () => {
margin-bottom: 12px;
}
/* ---- CONTRIBUTION AMOUNT INPUT + CHIPS ---- */
.contribution-input-row {
display: flex;
align-items: center;
gap: 0.25rem;
}
.contribution-currency {
font-weight: 600;
}
.contribution-input {
flex: 1;
padding: 0.5rem 0.75rem;
background: var(--input-bg);
border: 1px solid var(--parch);
font-family: 'Commit Mono', monospace;
font-size: 1rem;
}
.contribution-input:focus {
outline: none;
border-color: var(--candle);
}
.contribution-presets {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.contribution-preset-chip {
padding: 0.25rem 0.75rem;
background: transparent;
border: 1px dashed var(--parch);
font-family: 'Commit Mono', monospace;
font-size: 0.875rem;
cursor: pointer;
}
.contribution-preset-chip:hover {
border-style: solid;
border-color: var(--candle);
}
.contribution-guidance {
margin-top: 0.5rem;
font-size: 0.875rem;
font-style: italic;
color: var(--ink-soft, currentColor);
}
.btn-section {
width: 100%;
text-align: center;