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> <PageSection>
<div class="section-label">Change Contribution</div> <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"> <div class="tier-hint">
Changes take effect on your next billing cycle Changes take effect on your next billing cycle
</div> </div>
<button <button
class="btn btn-primary btn-section" class="btn btn-primary btn-section"
:disabled=" :disabled="
selectedTier === Number(memberData.contributionTier || 0) || form.contributionAmount === Number(memberData.contributionAmount || 0) ||
isUpdating isUpdating
" "
@click="handleUpdateTier" @click="handleUpdateTier"
@ -277,7 +305,7 @@
</template> </template>
<script setup> <script setup>
import { getTierAmount, getContributionTierByValue } from '~/config/contributions'; import { CONTRIBUTION_PRESETS, getGuidanceLabel, requiresPayment } from '~/config/contributions';
definePageMeta({ definePageMeta({
middleware: "auth", middleware: "auth",
@ -289,7 +317,7 @@ const { initializeHelcimPay, verifyPayment, cleanup: cleanupHelcimPay } = useHel
const toast = useToast(); const toast = useToast();
const helcimPortalUrl = useRuntimeConfig().public.helcimPortalUrl || ''; const helcimPortalUrl = useRuntimeConfig().public.helcimPortalUrl || '';
const selectedTier = ref(0); const form = reactive({ contributionAmount: 0 });
const selectedCircle = ref(""); const selectedCircle = ref("");
const isUpdating = ref(false); const isUpdating = ref(false);
const isCancelling = ref(false); const isCancelling = ref(false);
@ -315,37 +343,19 @@ const canChangeCard = computed(() => {
if (!m.helcimCustomerId) return false; if (!m.helcimCustomerId) return false;
if (!["active", "pending_payment"].includes(m.status)) return false; if (!["active", "pending_payment"].includes(m.status)) return false;
// $0 tier has no subscription to attach a card to // $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; return true;
}); });
const BASE_TIERS = [ const cadence = computed(() => memberData.value?.billingCadence || 'monthly');
{ 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 guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount));
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 currentContributionLabel = computed(() => {
const tier = getContributionTierByValue(String(memberData.value?.contributionTier || '0')); const amount = Number(memberData.value?.contributionAmount || 0);
const cadence = memberData.value?.billingCadence || 'monthly'; if (!amount) return '$0';
if (!tier || tier.amount === 0) return '$0'; const displayAmount = cadence.value === 'annual' ? amount * 12 : amount;
const amount = getTierAmount(tier, cadence); return cadence.value === 'annual' ? `$${displayAmount} / year` : `$${displayAmount} / month`;
return cadence === 'annual' ? `$${amount} / year` : `$${amount} / month`;
}); });
const circleOptions = [ const circleOptions = [
@ -380,7 +390,7 @@ const capitalise = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
// Initialize from member data // Initialize from member data
watchEffect(() => { watchEffect(() => {
if (memberData.value) { if (memberData.value) {
selectedTier.value = Number(memberData.value.contributionTier || 0); form.contributionAmount = Number(memberData.value.contributionAmount || 0);
selectedCircle.value = memberData.value.circle || "community"; selectedCircle.value = memberData.value.circle || "community";
} }
}); });
@ -399,8 +409,8 @@ const handleUpdateTier = async () => {
await $fetch("/api/members/update-contribution", { await $fetch("/api/members/update-contribution", {
method: "POST", method: "POST",
body: { body: {
contributionTier: String(selectedTier.value), contributionAmount: form.contributionAmount,
cadence: memberData.value?.billingCadence || 'monthly', cadence: cadence.value,
}, },
}); });
await checkMemberStatus(); await checkMemberStatus();
@ -409,13 +419,13 @@ const handleUpdateTier = async () => {
// Paid upgrade without a saved card route to payment setup instead of erroring. // Paid upgrade without a saved card route to payment setup instead of erroring.
if (err.data?.data?.requiresPaymentSetup) { if (err.data?.data?.requiresPaymentSetup) {
await navigateTo( await navigateTo(
`/member/payment-setup?tier=${selectedTier.value}&circle=${ `/member/payment-setup?tier=${form.contributionAmount}&circle=${
selectedCircle.value || memberData.value?.circle || 'community' selectedCircle.value || memberData.value?.circle || 'community'
}`, }`,
); );
return; return;
} }
selectedTier.value = Number(memberData.value?.contributionTier || 0); form.contributionAmount = Number(memberData.value?.contributionAmount || 0);
toast.add({ toast.add({
title: "Update failed", title: "Update failed",
description: err.data?.statusMessage || "Please try again.", description: err.data?.statusMessage || "Please try again.",
@ -797,6 +807,52 @@ const confirmCancelMembership = async () => {
margin-bottom: 12px; 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 { .btn-section {
width: 100%; width: 100%;
text-align: center; text-align: center;