ghostguild-org/app/components/ContributionAmountField.vue

297 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 }"
@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 },
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)
emit('update:modelValue', Number.isFinite(n) ? n : 0)
}
const selectPreset = (amount) => {
emit('update:modelValue', amount)
}
// Annual→monthly snap: floor(annual/12)*12 keeps the amount cleanly divisible
// before we divide, so toggling cadences never leaves fractional dollars.
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>