feat(frontend): rename contributionTier → contributionAmount across remaining pages

This commit is contained in:
Jennie Robinson Faber 2026-04-19 19:08:57 +01:00
parent 5ef0cc845f
commit b17e006d65
6 changed files with 133 additions and 81 deletions

View file

@ -62,7 +62,7 @@ export const useMemberPayment = () => {
body: { body: {
customerId: customerId.value, customerId: customerId.value,
customerCode: customerCode.value, customerCode: customerCode.value,
contributionTier: memberData.value?.contributionTier || '5', contributionAmount: memberData.value?.contributionAmount ?? 5,
cardToken: paymentResult.cardToken, cardToken: paymentResult.cardToken,
}, },
}) })

View file

@ -124,18 +124,31 @@
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
<label class="form-label" for="accept-tier">Monthly Contribution</label> <label class="form-label" for="accept-contribution">Monthly Contribution</label>
<select <div class="contribution-input-row">
id="accept-tier" <span class="contribution-currency">$</span>
v-model="form.contributionTier" <input
class="form-select" id="accept-contribution"
> v-model.number="form.contributionAmount"
<option value="0">$0/mo -- I need support right now</option> type="number"
<option value="5">$5/mo -- I can contribute</option> min="0"
<option value="15">$15/mo -- I can sustain the community (suggested)</option> step="1"
<option value="30">$30/mo -- I can support others too</option> inputmode="numeric"
<option value="50">$50/mo -- I want to sponsor multiple members</option> class="contribution-input"
</select> >
</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>
<p class="field-note">Pay what you can. If you can pay more, you're making room for someone who can't.</p> <p class="field-note">Pay what you can. If you can pay more, you're making room for someone who can't.</p>
</div> </div>
@ -171,7 +184,7 @@
<div v-else-if="step === 'payment'" class="form-container"> <div v-else-if="step === 'payment'" class="form-container">
<h1>Payment Information</h1> <h1>Payment Information</h1>
<p class="form-intro"> <p class="form-intro">
You're signing up for ${{ form.contributionTier }} CAD / month. You're signing up for ${{ form.contributionAmount }} CAD / month.
</p> </p>
<div v-if="errorMessage" class="error-box">{{ errorMessage }}</div> <div v-if="errorMessage" class="error-box">{{ errorMessage }}</div>
@ -199,7 +212,11 @@
</template> </template>
<script setup> <script setup>
import { requiresPayment } from "~/config/contributions"; import {
requiresPayment,
CONTRIBUTION_PRESETS,
getGuidanceLabel,
} from "~/config/contributions";
definePageMeta({ layout: false }); definePageMeta({ layout: false });
@ -218,18 +235,26 @@ const form = reactive({
location: "", location: "",
circle: "community", circle: "community",
motivation: "", motivation: "",
contributionTier: "15", contributionAmount: 15,
agreedToGuidelines: false, agreedToGuidelines: false,
}); });
const isFormValid = computed(() => { const isFormValid = computed(() => {
return form.name && form.circle && form.contributionTier && form.agreedToGuidelines; return (
form.name &&
form.circle &&
Number.isInteger(form.contributionAmount) &&
form.contributionAmount >= 0 &&
form.agreedToGuidelines
);
}); });
const needsPayment = computed(() => { const needsPayment = computed(() => {
return requiresPayment(form.contributionTier); return requiresPayment(form.contributionAmount);
}); });
const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount));
// Helcim state for paid tiers // Helcim state for paid tiers
const memberId = ref(null); const memberId = ref(null);
const customerId = ref(null); const customerId = ref(null);
@ -280,7 +305,7 @@ const handleAccept = async () => {
location: form.location || undefined, location: form.location || undefined,
circle: form.circle, circle: form.circle,
motivation: form.motivation || undefined, motivation: form.motivation || undefined,
contributionTier: form.contributionTier, contributionAmount: form.contributionAmount,
agreedToGuidelines: form.agreedToGuidelines, agreedToGuidelines: form.agreedToGuidelines,
token: token.value, token: token.value,
}, },
@ -314,7 +339,7 @@ const setupPayment = async (member) => {
name: member.name, name: member.name,
email: member.email, email: member.email,
circle: member.circle, circle: member.circle,
contributionTier: form.contributionTier, contributionAmount: form.contributionAmount,
}, },
}); });
@ -358,7 +383,7 @@ const processPayment = async () => {
body: { body: {
customerId: customerId.value, customerId: customerId.value,
customerCode: customerCode.value, customerCode: customerCode.value,
contributionTier: form.contributionTier, contributionAmount: form.contributionAmount,
cardToken: paymentResult.cardToken, cardToken: paymentResult.cardToken,
}, },
}); });
@ -487,6 +512,52 @@ textarea.form-input {
line-height: 1.4; line-height: 1.4;
} }
/* ---- 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);
}
/* ---- CIRCLE RADIOS ---- */ /* ---- CIRCLE RADIOS ---- */
.circle-radios { .circle-radios {
display: grid; display: grid;

View file

@ -54,14 +54,8 @@
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label>Contribution tier ($/mo)</label> <label>Contribution ($/mo)</label>
<select v-model="form.contributionTier"> <input v-model.number="form.contributionAmount" type="number" min="0" step="1">
<option value="0">$0</option>
<option value="5">$5</option>
<option value="15">$15</option>
<option value="30">$30</option>
<option value="50">$50</option>
</select>
</div> </div>
<div class="field"> <div class="field">
<label>Status</label> <label>Status</label>
@ -270,7 +264,7 @@ const form = reactive({
name: "", name: "",
email: "", email: "",
circle: "", circle: "",
contributionTier: "", contributionAmount: 0,
status: "", status: "",
role: "", role: "",
}); });
@ -282,7 +276,7 @@ function populateForm(m) {
form.name = m.name; form.name = m.name;
form.email = m.email; form.email = m.email;
form.circle = m.circle; form.circle = m.circle;
form.contributionTier = String(m.contributionTier); form.contributionAmount = m.contributionAmount ?? 0;
form.status = m.status || "pending_payment"; form.status = m.status || "pending_payment";
form.role = m.role || "member"; form.role = m.role || "member";
} }
@ -304,7 +298,7 @@ async function submitEdit() {
name: form.name, name: form.name,
email: form.email, email: form.email,
circle: form.circle, circle: form.circle,
contributionTier: form.contributionTier, contributionAmount: form.contributionAmount,
status: form.status, status: form.status,
}, },
}); });

View file

@ -77,7 +77,7 @@
<th class="sortable" @click="toggleSort('name')">Name <span class="sort-ind">{{ sortIndicator('name') }}</span></th> <th class="sortable" @click="toggleSort('name')">Name <span class="sort-ind">{{ sortIndicator('name') }}</span></th>
<th class="sortable" @click="toggleSort('email')">Email <span class="sort-ind">{{ sortIndicator('email') }}</span></th> <th class="sortable" @click="toggleSort('email')">Email <span class="sort-ind">{{ sortIndicator('email') }}</span></th>
<th class="sortable" @click="toggleSort('circle')">Circle <span class="sort-ind">{{ sortIndicator('circle') }}</span></th> <th class="sortable" @click="toggleSort('circle')">Circle <span class="sort-ind">{{ sortIndicator('circle') }}</span></th>
<th class="sortable" @click="toggleSort('contributionTier')">Tier <span class="sort-ind">{{ sortIndicator('contributionTier') }}</span></th> <th class="sortable" @click="toggleSort('contributionAmount')">Contribution <span class="sort-ind">{{ sortIndicator('contributionAmount') }}</span></th>
<th class="sortable" @click="toggleSort('status')">Status <span class="sort-ind">{{ sortIndicator('status') }}</span></th> <th class="sortable" @click="toggleSort('status')">Status <span class="sort-ind">{{ sortIndicator('status') }}</span></th>
<th>Invite</th> <th>Invite</th>
<th>Slack</th> <th>Slack</th>
@ -112,7 +112,7 @@
member.circle member.circle
}}</span> }}</span>
</td> </td>
<td class="col-mono">${{ member.contributionTier }}/mo</td> <td class="col-mono">${{ member.contributionAmount ?? 0 }}/mo</td>
<td> <td>
<span class="badge status" :class="`status-${member.status || 'pending_payment'}`">{{ statusLabel(member.status) }}</span> <span class="badge status" :class="`status-${member.status || 'pending_payment'}`">{{ statusLabel(member.status) }}</span>
</td> </td>
@ -186,14 +186,8 @@
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label>Contribution Tier</label> <label>Contribution ($/mo)</label>
<select v-model="newMember.contributionTier"> <input v-model.number="newMember.contributionAmount" type="number" min="0" step="1">
<option value="0">$0/month</option>
<option value="5">$5/month</option>
<option value="15">$15/month</option>
<option value="30">$30/month</option>
<option value="50">$50/month</option>
</select>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn" @click="showCreateModal = false"> <button type="button" class="btn" @click="showCreateModal = false">
@ -223,11 +217,10 @@
<div v-if="!csvRows.length"> <div v-if="!csvRows.length">
<p class="help-text"> <p class="help-text">
Upload a CSV with columns: Upload a CSV with columns:
<code>name,email,circle,contributionTier</code> <code>name,email,circle,contributionAmount</code>
</p> </p>
<p class="help-text" style="margin-bottom: 12px"> <p class="help-text" style="margin-bottom: 12px">
Valid circles: community, founder, practitioner. Valid tiers: 0, Valid circles: community, founder, practitioner. Contribution: whole number 0.
5, 15, 30, 50.
</p> </p>
<input <input
ref="csvFileInput" ref="csvFileInput"
@ -287,7 +280,7 @@
<td>{{ row.name }}</td> <td>{{ row.name }}</td>
<td class="col-email">{{ row.email }}</td> <td class="col-email">{{ row.email }}</td>
<td>{{ row.circle }}</td> <td>{{ row.circle }}</td>
<td>${{ row.contributionTier }}/mo</td> <td>${{ row.contributionAmount }}/mo</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -367,14 +360,8 @@
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label>Contribution Tier</label> <label>Contribution ($/mo)</label>
<select v-model="editingMember.contributionTier"> <input v-model.number="editingMember.contributionAmount" type="number" min="0" step="1">
<option value="0">$0/month</option>
<option value="5">$5/month</option>
<option value="15">$15/month</option>
<option value="30">$30/month</option>
<option value="50">$50/month</option>
</select>
</div> </div>
<div class="field"> <div class="field">
<label>Status</label> <label>Status</label>
@ -550,7 +537,7 @@ const newMember = reactive({
name: "", name: "",
email: "", email: "",
circle: "community", circle: "community",
contributionTier: "0", contributionAmount: 0,
}); });
const filteredMembers = computed(() => { const filteredMembers = computed(() => {
@ -576,7 +563,7 @@ const filteredMembers = computed(() => {
return [...filtered].sort((a, b) => { return [...filtered].sort((a, b) => {
let av = a[key]; let av = a[key];
let bv = b[key]; let bv = b[key];
if (key === "contributionTier") { if (key === "contributionAmount") {
av = Number(av) || 0; av = Number(av) || 0;
bv = Number(bv) || 0; bv = Number(bv) || 0;
} else if (key === "createdAt") { } else if (key === "createdAt") {
@ -668,7 +655,7 @@ const createMember = async () => {
name: "", name: "",
email: "", email: "",
circle: "community", circle: "community",
contributionTier: "0", contributionAmount: 0,
}); });
await refresh(); await refresh();
@ -687,7 +674,6 @@ const createMember = async () => {
// --- CSV Import --- // --- CSV Import ---
const VALID_CIRCLES = ["community", "founder", "practitioner"]; const VALID_CIRCLES = ["community", "founder", "practitioner"];
const VALID_TIERS = ["0", "5", "15", "30", "50"];
const handleCsvFile = (event) => { const handleCsvFile = (event) => {
const file = event.target.files[0]; const file = event.target.files[0];
@ -716,10 +702,10 @@ const parseCsv = (text) => {
const nameIdx = header.indexOf("name"); const nameIdx = header.indexOf("name");
const emailIdx = header.indexOf("email"); const emailIdx = header.indexOf("email");
const circleIdx = header.indexOf("circle"); const circleIdx = header.indexOf("circle");
const tierIdx = header.indexOf("contributiontier"); const amountIdx = header.indexOf("contributionamount");
if (nameIdx === -1 || emailIdx === -1 || circleIdx === -1 || tierIdx === -1) { if (nameIdx === -1 || emailIdx === -1 || circleIdx === -1 || amountIdx === -1) {
csvParseError.value = `Missing required columns. Found: ${header.join(", ")}. Need: name, email, circle, contributionTier`; csvParseError.value = `Missing required columns. Found: ${header.join(", ")}. Need: name, email, circle, contributionAmount`;
return; return;
} }
@ -731,20 +717,21 @@ const parseCsv = (text) => {
const name = cols[nameIdx] || ""; const name = cols[nameIdx] || "";
const email = (cols[emailIdx] || "").toLowerCase(); const email = (cols[emailIdx] || "").toLowerCase();
const circle = (cols[circleIdx] || "").toLowerCase(); const circle = (cols[circleIdx] || "").toLowerCase();
const contributionTier = cols[tierIdx] || ""; const rawAmount = cols[amountIdx] || "";
const contributionAmount = Number(rawAmount);
let error = null; let error = null;
if (!name) error = "Missing name"; if (!name) error = "Missing name";
else if (!email || !email.includes("@")) error = "Invalid email"; else if (!email || !email.includes("@")) error = "Invalid email";
else if (!VALID_CIRCLES.includes(circle)) else if (!VALID_CIRCLES.includes(circle))
error = `Invalid circle: ${circle}`; error = `Invalid circle: ${circle}`;
else if (!VALID_TIERS.includes(contributionTier)) else if (!Number.isInteger(contributionAmount) || contributionAmount < 0)
error = `Invalid tier: ${contributionTier}`; error = `Invalid contribution: ${rawAmount}`;
else if (seenEmails.has(email)) error = "Duplicate email in CSV"; else if (seenEmails.has(email)) error = "Duplicate email in CSV";
if (!error) seenEmails.add(email); if (!error) seenEmails.add(email);
rows.push({ name, email, circle, contributionTier, error }); rows.push({ name, email, circle, contributionAmount, error });
} }
csvRows.value = rows; csvRows.value = rows;
@ -771,11 +758,11 @@ const submitImport = async () => {
importing.value = true; importing.value = true;
try { try {
const payload = csvValidRows.value.map( const payload = csvValidRows.value.map(
({ name, email, circle, contributionTier }) => ({ ({ name, email, circle, contributionAmount }) => ({
name, name,
email, email,
circle, circle,
contributionTier, contributionAmount,
}), }),
); );
@ -854,7 +841,7 @@ const editingMember = reactive({
name: "", name: "",
email: "", email: "",
circle: "community", circle: "community",
contributionTier: "0", contributionAmount: 0,
status: "pending_payment", status: "pending_payment",
}); });
@ -864,7 +851,7 @@ const editMember = (member) => {
name: member.name, name: member.name,
email: member.email, email: member.email,
circle: member.circle, circle: member.circle,
contributionTier: String(member.contributionTier), contributionAmount: member.contributionAmount ?? 0,
status: member.status || "pending_payment", status: member.status || "pending_payment",
}); });
showEditModal.value = true; showEditModal.value = true;
@ -879,7 +866,7 @@ const submitEditMember = async () => {
name: editingMember.name, name: editingMember.name,
email: editingMember.email, email: editingMember.email,
circle: editingMember.circle, circle: editingMember.circle,
contributionTier: editingMember.contributionTier, contributionAmount: editingMember.contributionAmount,
status: editingMember.status, status: editingMember.status,
}, },
}); });

View file

@ -36,7 +36,7 @@
<PageHeader :title="`Welcome back, ${memberData?.name || ''}`"> <PageHeader :title="`Welcome back, ${memberData?.name || ''}`">
<div class="dashboard-meta"> <div class="dashboard-meta">
<CircleBadge :circle="memberData?.circle || 'community'" /> <CircleBadge :circle="memberData?.circle || 'community'" />
<span>${{ memberData?.contributionTier }} CAD/mo</span> <span>${{ memberData?.contributionAmount ?? 0 }} CAD/mo</span>
</div> </div>
</PageHeader> </PageHeader>
@ -169,7 +169,7 @@
<div class="membership-row"> <div class="membership-row">
<span class="key">Contribution</span> <span class="key">Contribution</span>
<span class="val" <span class="val"
>${{ memberData?.contributionTier }} CAD/month</span >${{ memberData?.contributionAmount ?? 0 }} CAD/month</span
> >
</div> </div>
<div class="membership-row"> <div class="membership-row">

View file

@ -3,7 +3,7 @@
<ClientOnly> <ClientOnly>
<PageHeader <PageHeader
title="Set Up Payment" title="Set Up Payment"
:subtitle="targetTier ? `Upgrading to $${targetTier}/month` : 'Payment setup'" :subtitle="targetAmount != null ? `Upgrading to $${targetAmount}/month` : 'Payment setup'"
/> />
<PageSection> <PageSection>
@ -21,7 +21,7 @@
<div v-else-if="step === 'ready'" class="status-block"> <div v-else-if="step === 'ready'" class="status-block">
<p> <p>
To upgrade to <strong>${{ targetTier }}/month</strong>, we need a To upgrade to <strong>${{ targetAmount }}/month</strong>, we need a
payment method on file. Click below to open the secure payment payment method on file. Click below to open the secure payment
form we'll verify your card with a $0 authorization and then form we'll verify your card with a $0 authorization and then
activate your new tier. activate your new tier.
@ -56,12 +56,12 @@ const toast = useToast();
const { memberData, checkMemberStatus } = useAuth(); const { memberData, checkMemberStatus } = useAuth();
const { initializeHelcimPay, verifyPayment, cleanup: cleanupHelcim } = useHelcimPay(); const { initializeHelcimPay, verifyPayment, cleanup: cleanupHelcim } = useHelcimPay();
const VALID_TIERS = ['5', '15', '30', '50']; const VALID_AMOUNTS = [5, 15, 30, 50];
const VALID_CIRCLES = ['community', 'founder', 'practitioner']; const VALID_CIRCLES = ['community', 'founder', 'practitioner'];
const targetTier = computed(() => { const targetAmount = computed(() => {
const t = String(route.query.tier || ''); const n = Number(route.query.tier);
return VALID_TIERS.includes(t) ? t : null; return VALID_AMOUNTS.includes(n) ? n : null;
}); });
const targetCircle = computed(() => { const targetCircle = computed(() => {
const c = String(route.query.circle || ''); const c = String(route.query.circle || '');
@ -78,8 +78,8 @@ const initialize = async () => {
errorMessage.value = ''; errorMessage.value = '';
step.value = 'loading'; step.value = 'loading';
if (!targetTier.value) { if (targetAmount.value == null) {
errorMessage.value = 'Missing or invalid target tier.'; errorMessage.value = 'Missing or invalid target amount.';
step.value = 'error'; step.value = 'error';
return; return;
} }
@ -129,7 +129,7 @@ const openModal = async () => {
await $fetch('/api/members/update-contribution', { await $fetch('/api/members/update-contribution', {
method: 'POST', method: 'POST',
// cadence: annual upgrades go through /join; this page is monthly-only // cadence: annual upgrades go through /join; this page is monthly-only
body: { contributionTier: targetTier.value, cadence: 'monthly' }, body: { contributionAmount: targetAmount.value, cadence: 'monthly' },
}); });
await checkMemberStatus(); await checkMemberStatus();