feat(contribution): port accept-invite.vue to ContributionAmountField

Replace cadence radios, contribution input, preset chips, guidance label,
and billing summary block with a single ContributionAmountField usage.
Default contribution updated to 180 to preserve the previous $15/mo
suggested annual default (cadence-unit value now). Updated flowSummary to
format cadence-unit directly. Updated e2e selectors to use the data-testids
the component exposes and new summary copy.
This commit is contained in:
Jennie Robinson Faber 2026-05-23 15:14:33 +01:00
parent 3126ddb8ea
commit e0e7da5cca
2 changed files with 29 additions and 181 deletions

View file

@ -124,77 +124,13 @@
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
<label class="form-label">Billing Cadence</label> <ContributionAmountField
<div class="cadence-radios"> v-model="form.contributionAmount"
<div class="circle-radio"> v-model:cadence="cadence"
<input />
id="accept-cadence-annual"
v-model="cadence"
type="radio"
name="cadence"
value="annual"
>
<label for="accept-cadence-annual">
<span class="circle-label-name">Per Year</span>
</label>
</div>
<div class="circle-radio">
<input
id="accept-cadence-monthly"
v-model="cadence"
type="radio"
name="cadence"
value="monthly"
>
<label for="accept-cadence-monthly">
<span class="circle-label-name">Per Month</span>
</label>
</div>
</div>
</div>
<div class="form-group full-width">
<label class="form-label" for="accept-contribution">
Monthly Contribution
</label>
<div class="contribution-input-row">
<span class="contribution-currency">$</span>
<input
id="accept-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>
<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>
<div v-if="form.contributionAmount > 0" class="form-group full-width">
<div class="billing-summary">
<p class="billing-summary-line">
You'll be charged <strong>${{ firstCharge }} today</strong><span v-if="cadence === 'annual'"> (${{ form.contributionAmount }}/month &times; 12)</span>.
</p>
<p class="billing-summary-line">
Then <strong>${{ firstCharge }} every {{ cadence === 'annual' ? 'year' : 'month' }}</strong>, until you cancel.
</p>
</div>
</div>
<div class="form-group full-width"> <div class="form-group full-width">
<label class="checkbox-label"> <label class="checkbox-label">
<input <input
@ -236,11 +172,7 @@
</template> </template>
<script setup> <script setup>
import { import { requiresPayment } from "~/config/contributions";
requiresPayment,
CONTRIBUTION_PRESETS,
getGuidanceLabel,
} from "~/config/contributions";
definePageMeta({ layout: false }); definePageMeta({ layout: false });
useSiteMeta({ title: "Accept Invitation", noindex: true }); useSiteMeta({ title: "Accept Invitation", noindex: true });
@ -265,7 +197,7 @@ const form = reactive({
location: "", location: "",
circle: "community", circle: "community",
motivation: "", motivation: "",
contributionAmount: 15, contributionAmount: 180,
agreedToGuidelines: false, agreedToGuidelines: false,
}); });
@ -283,26 +215,16 @@ const needsPayment = computed(() => {
return requiresPayment(form.contributionAmount); return requiresPayment(form.contributionAmount);
}); });
const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount)); const flowSummary = computed(() => {
const firstCharge = computed(() => {
const amount = form.contributionAmount || 0; const amount = form.contributionAmount || 0;
return cadence.value === "annual" ? amount * 12 : amount;
});
const formatContributionAmount = (amount) => {
if (!amount || amount === 0) return "$0";
const display = cadence.value === "annual" ? amount * 12 : amount;
const suffix = cadence.value === "annual" ? "/yr" : "/mo"; const suffix = cadence.value === "annual" ? "/yr" : "/mo";
return `$${display}${suffix}`; return {
}; name: form.name,
email: preRegEmail.value,
const flowSummary = computed(() => ({ circle: form.circle,
name: form.name, contribution: amount > 0 ? `$${amount}${suffix}` : "$0",
email: preRegEmail.value, };
circle: form.circle, });
contribution: formatContributionAmount(form.contributionAmount),
}));
const closeFlowOverlay = () => { const closeFlowOverlay = () => {
flowState.value = "idle"; flowState.value = "idle";
@ -525,72 +447,6 @@ 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);
}
/* ---- BILLING SUMMARY ---- */
.billing-summary {
padding: 12px 16px;
border: 1px dashed var(--border);
background: var(--surface);
}
.billing-summary-line {
font-size: 13px;
color: var(--text);
line-height: 1.5;
margin: 0;
}
.billing-summary-line + .billing-summary-line {
margin-top: 4px;
}
.billing-summary-line strong {
color: var(--text-bright);
font-weight: 600;
}
/* ---- CIRCLE RADIOS ---- */ /* ---- CIRCLE RADIOS ---- */
.circle-radios { .circle-radios {
display: grid; display: grid;
@ -598,12 +454,6 @@ textarea.form-input {
gap: 8px; gap: 8px;
} }
.cadence-radios {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.circle-radio { .circle-radio {
position: relative; position: relative;
} }
@ -721,9 +571,5 @@ textarea.form-input {
.circle-radios { .circle-radios {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.cadence-radios {
grid-template-columns: 1fr;
}
} }
</style> </style>

View file

@ -66,9 +66,9 @@ test.describe('Accept Invite — pre-registrant signup', () => {
await expect(page.locator('#circle-community')).toBeAttached() await expect(page.locator('#circle-community')).toBeAttached()
await expect(page.locator('#circle-founder')).toBeAttached() await expect(page.locator('#circle-founder')).toBeAttached()
await expect(page.locator('#circle-practitioner')).toBeAttached() await expect(page.locator('#circle-practitioner')).toBeAttached()
await expect(page.locator('#accept-cadence-monthly')).toBeAttached() await expect(page.getByTestId('cadence-monthly')).toBeVisible()
await expect(page.locator('#accept-cadence-annual')).toBeAttached() await expect(page.getByTestId('cadence-annual')).toBeVisible()
await expect(page.locator('#accept-contribution')).toBeVisible() await expect(page.getByTestId('contribution-amount')).toBeVisible()
await expect(page.locator('.contribution-preset-chip').first()).toBeVisible() await expect(page.locator('.contribution-preset-chip').first()).toBeVisible()
await expect(page.locator('.form-submit')).toBeVisible() await expect(page.locator('.form-submit')).toBeVisible()
}) })
@ -110,15 +110,17 @@ test.describe('Accept Invite — pre-registrant signup', () => {
await mockVerifyOk(page) await mockVerifyOk(page)
await gotoAcceptInvite(page) await gotoAcceptInvite(page)
await expect(page.locator('#accept-contribution')).toBeVisible() await expect(page.getByTestId('contribution-amount')).toBeVisible()
await page.locator('#accept-contribution').fill('10') await page.getByTestId('cadence-monthly').click()
await page.getByTestId('contribution-amount').fill('10')
await page.locator('label[for="accept-cadence-monthly"]').click() const summary = page.locator('.billing-summary')
await expect(page.locator('.billing-summary')).toContainText('$10 today') await expect(summary).toContainText('$10 today')
await expect(summary).toContainText('each month')
await page.locator('label[for="accept-cadence-annual"]').click() await page.getByTestId('cadence-annual').click()
await expect(page.locator('.billing-summary')).toContainText('$120 today') await expect(summary).toContainText('$120 today')
await expect(page.locator('.billing-summary')).toContainText('$10/month') await expect(summary).toContainText('at each annual renewal')
}) })
test('preset chip sets contribution amount', async ({ page }) => { test('preset chip sets contribution amount', async ({ page }) => {
@ -131,7 +133,7 @@ test.describe('Accept Invite — pre-registrant signup', () => {
const expected = chipText.replace(/[^0-9]/g, '') const expected = chipText.replace(/[^0-9]/g, '')
await chip.click() await chip.click()
await expect(page.locator('#accept-contribution')).toHaveValue(expected) await expect(page.getByTestId('contribution-amount')).toHaveValue(expected)
}) })
test('free tier happy path shows welcome state', async ({ page }) => { test('free tier happy path shows welcome state', async ({ page }) => {
@ -141,7 +143,7 @@ test.describe('Accept Invite — pre-registrant signup', () => {
await expect(page.locator('#accept-name')).toHaveValue('Free Tester') await expect(page.locator('#accept-name')).toHaveValue('Free Tester')
await page.locator('#circle-community').check({ force: true }) await page.locator('#circle-community').check({ force: true })
await page.locator('#accept-contribution').fill('0') await page.getByTestId('contribution-amount').fill('0')
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check() await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
await expect(page.locator('.form-submit')).toBeEnabled() await expect(page.locator('.form-submit')).toBeEnabled()
@ -158,7 +160,7 @@ test.describe('Accept Invite — pre-registrant signup', () => {
await mockVerifyOk(page) await mockVerifyOk(page)
await gotoAcceptInvite(page) await gotoAcceptInvite(page)
await page.locator('#accept-contribution').fill('10') await page.getByTestId('contribution-amount').fill('10')
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check() await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
await expect(page.locator('.form-submit')).toContainText(/Continue to Payment/) await expect(page.locator('.form-submit')).toContainText(/Continue to Payment/)
}) })