301 lines
7.2 KiB
Vue
301 lines
7.2 KiB
Vue
<template>
|
|
<div class="contribution-amount-field">
|
|
<div v-if="allowCadenceChange" class="form-group">
|
|
<label class="form-label">How often</label>
|
|
<div class="cadence-radios">
|
|
<div class="circle-radio">
|
|
<input
|
|
:id="`${uid}-cadence-monthly`"
|
|
type="radio"
|
|
:name="`${uid}-cadence`"
|
|
value="monthly"
|
|
:checked="cadence === 'monthly'"
|
|
@change="onCadenceChange('monthly')"
|
|
>
|
|
<label :for="`${uid}-cadence-monthly`">
|
|
<span class="circle-label-name">Per Month</span>
|
|
</label>
|
|
</div>
|
|
<div class="circle-radio">
|
|
<input
|
|
:id="`${uid}-cadence-annual`"
|
|
type="radio"
|
|
:name="`${uid}-cadence`"
|
|
value="annual"
|
|
:checked="cadence === 'annual'"
|
|
@change="onCadenceChange('annual')"
|
|
>
|
|
<label :for="`${uid}-cadence-annual`">
|
|
<span class="circle-label-name">Per Year</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label" :for="`${uid}-amount`">Contribution</label>
|
|
<div class="contribution-input-row">
|
|
<span class="contribution-currency">$</span>
|
|
<input
|
|
:id="`${uid}-amount`"
|
|
:value="modelValue"
|
|
type="number"
|
|
min="0"
|
|
step="1"
|
|
inputmode="numeric"
|
|
class="contribution-input"
|
|
@input="onAmountInput"
|
|
>
|
|
</div>
|
|
<div
|
|
class="contribution-presets"
|
|
role="group"
|
|
aria-label="Suggested amounts"
|
|
>
|
|
<button
|
|
v-for="preset in presetChips"
|
|
:key="preset.amount"
|
|
type="button"
|
|
class="contribution-preset-chip"
|
|
:class="{ active: numericAmount === preset.amount }"
|
|
:aria-pressed="numericAmount === preset.amount"
|
|
@click="selectPreset(preset.amount)"
|
|
>
|
|
${{ preset.amount }}
|
|
</button>
|
|
</div>
|
|
<p v-if="guidanceLabel" class="contribution-guidance">
|
|
{{ guidanceLabel }}
|
|
</p>
|
|
<p v-if="showSoftMax" class="contribution-soft-max">
|
|
Whoa, that's a lot. Are you sure that's the amount you meant?
|
|
</p>
|
|
</div>
|
|
|
|
<div v-if="showSummary && numericAmount > 0" class="form-group">
|
|
<div class="billing-summary">
|
|
<p class="billing-summary-line">
|
|
You'll be charged <strong>${{ numericAmount }} today</strong>.
|
|
</p>
|
|
<p class="billing-summary-line">
|
|
Then <strong>${{ numericAmount }}</strong>
|
|
{{ cadence === 'annual' ? 'at each annual renewal.' : 'each month.' }}
|
|
</p>
|
|
<p v-if="summaryNote" class="billing-summary-line billing-summary-note">
|
|
{{ summaryNote }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, useId } from 'vue'
|
|
import {
|
|
CONTRIBUTION_PRESETS,
|
|
getGuidanceLabel,
|
|
} from '~/config/contributions.js'
|
|
|
|
const props = defineProps({
|
|
modelValue: { type: Number, required: true },
|
|
cadence: {
|
|
type: String,
|
|
required: true,
|
|
validator: (v) => v === 'monthly' || v === 'annual',
|
|
},
|
|
allowCadenceChange: { type: Boolean, default: true },
|
|
showSummary: { type: Boolean, default: true },
|
|
summaryNote: { type: String, default: '' },
|
|
})
|
|
|
|
const emit = defineEmits(['update:modelValue', 'update:cadence'])
|
|
|
|
const uid = useId()
|
|
|
|
const numericAmount = computed(() => {
|
|
const n = Number(props.modelValue)
|
|
return Number.isFinite(n) ? n : 0
|
|
})
|
|
|
|
const monthlyEquivalent = computed(() =>
|
|
props.cadence === 'annual' ? numericAmount.value / 12 : numericAmount.value,
|
|
)
|
|
|
|
const presetChips = computed(() =>
|
|
CONTRIBUTION_PRESETS.map((p) => ({
|
|
amount: props.cadence === 'annual' ? p.amount * 12 : p.amount,
|
|
label: p.label,
|
|
})),
|
|
)
|
|
|
|
const guidanceLabel = computed(() => getGuidanceLabel(monthlyEquivalent.value))
|
|
|
|
const showSoftMax = computed(() => monthlyEquivalent.value > 500)
|
|
|
|
const onAmountInput = (event) => {
|
|
const raw = event.target.value
|
|
if (raw === '') {
|
|
emit('update:modelValue', 0)
|
|
return
|
|
}
|
|
const n = Number(raw)
|
|
const sanitized = Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : 0
|
|
emit('update:modelValue', sanitized)
|
|
}
|
|
|
|
const selectPreset = (amount) => {
|
|
emit('update:modelValue', amount)
|
|
}
|
|
|
|
const onCadenceChange = (newCadence) => {
|
|
if (newCadence === props.cadence) return
|
|
const current = numericAmount.value
|
|
let next = current
|
|
if (newCadence === 'annual') {
|
|
next = current * 12
|
|
} else {
|
|
next = Math.floor(current / 12)
|
|
}
|
|
emit('update:cadence', newCadence)
|
|
emit('update:modelValue', next)
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.form-group {
|
|
margin-bottom: 16px;
|
|
}
|
|
.form-group:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
.form-label {
|
|
display: block;
|
|
margin-bottom: 6px;
|
|
font-size: 10px;
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
color: var(--text-faint);
|
|
}
|
|
|
|
.cadence-radios {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 10px;
|
|
}
|
|
.circle-radio {
|
|
position: relative;
|
|
}
|
|
.circle-radio input {
|
|
position: absolute;
|
|
opacity: 0;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
.circle-radio label {
|
|
display: block;
|
|
border: 1px dashed var(--border);
|
|
padding: 14px 12px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
text-align: center;
|
|
}
|
|
.circle-radio label:hover {
|
|
border-color: var(--candle-faint);
|
|
}
|
|
.circle-radio input:checked + label {
|
|
border-style: solid;
|
|
border-color: var(--candle);
|
|
}
|
|
.circle-radio input:checked + label .circle-label-name {
|
|
color: var(--text-bright);
|
|
}
|
|
.circle-label-name {
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
display: block;
|
|
}
|
|
|
|
.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;
|
|
color: var(--text-bright);
|
|
}
|
|
.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;
|
|
color: var(--text);
|
|
}
|
|
.contribution-preset-chip:hover {
|
|
border-style: solid;
|
|
border-color: var(--candle);
|
|
}
|
|
.contribution-preset-chip.active {
|
|
border-style: solid;
|
|
border-color: var(--candle);
|
|
background: var(--parch);
|
|
color: var(--text-bright);
|
|
}
|
|
|
|
.contribution-guidance {
|
|
margin-top: 0.5rem;
|
|
font-size: 0.875rem;
|
|
font-style: italic;
|
|
color: var(--text);
|
|
}
|
|
.contribution-soft-max {
|
|
margin-top: 0.5rem;
|
|
font-size: 0.875rem;
|
|
font-style: italic;
|
|
color: var(--ember);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
.billing-summary-note {
|
|
color: var(--text-dim);
|
|
font-style: italic;
|
|
}
|
|
</style>
|