Compare commits
21 commits
1c3273cee2
...
10a28ac5ef
| Author | SHA1 | Date | |
|---|---|---|---|
| 10a28ac5ef | |||
| 151481f1ec | |||
| 7beb86b430 | |||
| 039a6802e3 | |||
| fee5959818 | |||
| c85b2ae3d9 | |||
| 5d4321612f | |||
| e5f1e9f95e | |||
| 10f8cab6e3 | |||
| 1079e8212f | |||
| ad63a37a05 | |||
| aa6a176fb9 | |||
| f848773887 | |||
| 0dd68ff1aa | |||
| 5023fb14ad | |||
| e0e7da5cca | |||
| 3126ddb8ea | |||
| 2f229cbfa0 | |||
| 26ee1ca60d | |||
| f28558a433 | |||
| 81783866d1 |
35 changed files with 1801 additions and 1191 deletions
302
app/components/ContributionAmountField.vue
Normal file
302
app/components/ContributionAmountField.vue
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
<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`" data-testid="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`" data-testid="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"
|
||||||
|
data-testid="contribution-amount"
|
||||||
|
@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(--parch-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
|
|
@ -20,3 +20,12 @@ export const getGuidanceLabel = (amount) => {
|
||||||
const match = CONTRIBUTION_PRESETS.findLast(p => p.amount <= n)
|
const match = CONTRIBUTION_PRESETS.findLast(p => p.amount <= n)
|
||||||
return match?.label ?? null
|
return match?.label ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format a contribution amount with cadence-aware suffix.
|
||||||
|
// amount is interpreted in cadence units (e.g. 180 + 'annual' → "$180/yr").
|
||||||
|
export const formatContribution = (amount, cadence) => {
|
||||||
|
const n = Number(amount) || 0
|
||||||
|
if (n === 0) return '$0'
|
||||||
|
const suffix = cadence === 'annual' ? '/yr' : '/mo'
|
||||||
|
return `$${n}${suffix}`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/admin/series-management"
|
to="/admin/series"
|
||||||
:class="{ active: route.path.includes('/admin/series') }"
|
:class="{ active: route.path.includes('/admin/series') }"
|
||||||
>
|
>
|
||||||
Series
|
Series
|
||||||
|
|
@ -66,6 +66,14 @@
|
||||||
Board Channels
|
Board Channels
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin/tags"
|
||||||
|
:class="{ active: route.path.startsWith('/admin/tags') }"
|
||||||
|
>
|
||||||
|
Tags
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/admin/site-content"
|
to="/admin/site-content"
|
||||||
|
|
@ -153,7 +161,7 @@
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/admin/series-management"
|
to="/admin/series"
|
||||||
:class="{ active: route.path.includes('/admin/series') }"
|
:class="{ active: route.path.includes('/admin/series') }"
|
||||||
@click="isMobileMenuOpen = false"
|
@click="isMobileMenuOpen = false"
|
||||||
>
|
>
|
||||||
|
|
@ -178,6 +186,15 @@
|
||||||
Board Channels
|
Board Channels
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin/tags"
|
||||||
|
:class="{ active: route.path.startsWith('/admin/tags') }"
|
||||||
|
@click="isMobileMenuOpen = false"
|
||||||
|
>
|
||||||
|
Tags
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/admin/site-content"
|
to="/admin/site-content"
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,10 @@
|
||||||
class="form-input"
|
class="form-input"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
|
@blur="validateName"
|
||||||
|
@input="fieldErrors.name && (fieldErrors.name = '')"
|
||||||
>
|
>
|
||||||
|
<p v-if="fieldErrors.name" class="field-error">{{ fieldErrors.name }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="accept-email">Email</label>
|
<label class="form-label" for="accept-email">Email</label>
|
||||||
|
|
@ -68,7 +71,7 @@
|
||||||
|
|
||||||
<div class="form-group full-width">
|
<div class="form-group full-width">
|
||||||
<label class="form-label">Circle</label>
|
<label class="form-label">Circle</label>
|
||||||
<p class="field-note" style="margin-bottom: 8px">Which circle fits where you are right now?</p>
|
<p class="field-note" style="margin-bottom: 8px">Where you are in your co-op journey. You can change this anytime.</p>
|
||||||
<div class="circle-radios">
|
<div class="circle-radios">
|
||||||
<div class="circle-radio community">
|
<div class="circle-radio community">
|
||||||
<input
|
<input
|
||||||
|
|
@ -124,77 +127,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 × 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 +175,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,10 +200,17 @@ const form = reactive({
|
||||||
location: "",
|
location: "",
|
||||||
circle: "community",
|
circle: "community",
|
||||||
motivation: "",
|
motivation: "",
|
||||||
contributionAmount: 15,
|
contributionAmount: 180,
|
||||||
agreedToGuidelines: false,
|
agreedToGuidelines: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Inline blur validation (UI feedback only — does not block submission)
|
||||||
|
const fieldErrors = reactive({ name: "" });
|
||||||
|
|
||||||
|
const validateName = () => {
|
||||||
|
fieldErrors.name = form.name.trim() ? "" : "Please enter your name.";
|
||||||
|
};
|
||||||
|
|
||||||
const isFormValid = computed(() => {
|
const isFormValid = computed(() => {
|
||||||
return (
|
return (
|
||||||
form.name &&
|
form.name &&
|
||||||
|
|
@ -283,26 +225,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 {
|
||||||
};
|
|
||||||
|
|
||||||
const flowSummary = computed(() => ({
|
|
||||||
name: form.name,
|
name: form.name,
|
||||||
email: preRegEmail.value,
|
email: preRegEmail.value,
|
||||||
circle: form.circle,
|
circle: form.circle,
|
||||||
contribution: formatContributionAmount(form.contributionAmount),
|
contribution: amount > 0 ? `$${amount}${suffix}` : "$0",
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const closeFlowOverlay = () => {
|
const closeFlowOverlay = () => {
|
||||||
flowState.value = "idle";
|
flowState.value = "idle";
|
||||||
|
|
@ -525,71 +457,11 @@ textarea.form-input {
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- CONTRIBUTION AMOUNT INPUT + CHIPS ---- */
|
.field-error {
|
||||||
.contribution-input-row {
|
font-size: 11px;
|
||||||
display: flex;
|
color: var(--ember);
|
||||||
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;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
.billing-summary-line strong {
|
|
||||||
color: var(--text-bright);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- CIRCLE RADIOS ---- */
|
/* ---- CIRCLE RADIOS ---- */
|
||||||
.circle-radios {
|
.circle-radios {
|
||||||
|
|
@ -598,12 +470,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 +587,5 @@ textarea.form-input {
|
||||||
.circle-radios {
|
.circle-radios {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cadence-radios {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -350,6 +350,7 @@
|
||||||
<label>New tag pool</label>
|
<label>New tag pool</label>
|
||||||
<USelect
|
<USelect
|
||||||
v-model="newTagPool"
|
v-model="newTagPool"
|
||||||
|
aria-label="New tag pool"
|
||||||
:items="[
|
:items="[
|
||||||
{ label: 'Cooperative', value: 'cooperative' },
|
{ label: 'Cooperative', value: 'cooperative' },
|
||||||
{ label: 'Craft', value: 'craft' },
|
{ label: 'Craft', value: 'craft' },
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Contribution ($/mo)</label>
|
<label>Contribution ({{ member.billingCadence === 'annual' ? '$/yr' : '$/mo' }})</label>
|
||||||
<input v-model.number="form.contributionAmount" type="number" min="0" step="1">
|
<input v-model.number="form.contributionAmount" type="number" min="0" step="1">
|
||||||
<p class="field-hint field-hint--warn">
|
<p class="field-hint field-hint--warn">
|
||||||
Writes to our database only. If the member is on a paid plan, also update <code>recurringAmount</code> in the Helcim dashboard — this form does not sync.
|
Writes to our database only. If the member is on a paid plan, also update <code>recurringAmount</code> in the Helcim dashboard — this form does not sync.
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@
|
||||||
<td>
|
<td>
|
||||||
<CircleBadge :circle="member.circle" />
|
<CircleBadge :circle="member.circle" />
|
||||||
</td>
|
</td>
|
||||||
<td class="col-mono">${{ member.contributionAmount ?? 0 }}/mo</td>
|
<td class="col-mono">${{ member.contributionAmount ?? 0 }}{{ member.billingCadence === 'annual' ? '/yr' : '/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>
|
||||||
|
|
@ -366,7 +366,7 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Contribution ($/mo)</label>
|
<label>Contribution ({{ editingMember.billingCadence === 'annual' ? '$/yr' : '$/mo' }})</label>
|
||||||
<input v-model.number="editingMember.contributionAmount" type="number" min="0" step="1">
|
<input v-model.number="editingMember.contributionAmount" type="number" min="0" step="1">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
@ -860,6 +860,7 @@ const editingMember = reactive({
|
||||||
email: "",
|
email: "",
|
||||||
circle: "community",
|
circle: "community",
|
||||||
contributionAmount: 0,
|
contributionAmount: 0,
|
||||||
|
billingCadence: "monthly",
|
||||||
status: "pending_payment",
|
status: "pending_payment",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -870,6 +871,7 @@ const editMember = (member) => {
|
||||||
email: member.email,
|
email: member.email,
|
||||||
circle: member.circle,
|
circle: member.circle,
|
||||||
contributionAmount: member.contributionAmount ?? 0,
|
contributionAmount: member.contributionAmount ?? 0,
|
||||||
|
billingCadence: member.billingCadence || "monthly",
|
||||||
status: member.status || "pending_payment",
|
status: member.status || "pending_payment",
|
||||||
});
|
});
|
||||||
showEditModal.value = true;
|
showEditModal.value = true;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="create-form">
|
<div class="create-form">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<NuxtLink to="/admin/series-management" class="back-link">← Series</NuxtLink>
|
<NuxtLink to="/admin/series" class="back-link">← Series</NuxtLink>
|
||||||
<h1>Create New Series</h1>
|
<h1>Create New Series</h1>
|
||||||
<p>Create a new event series to group related events together</p>
|
<p>Create a new event series to group related events together</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -98,7 +98,7 @@
|
||||||
<!-- Form Actions -->
|
<!-- Form Actions -->
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/admin/series-management"
|
to="/admin/series"
|
||||||
class="btn"
|
class="btn"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|
@ -211,7 +211,7 @@ const createSeries = async (redirectAfter = true) => {
|
||||||
|
|
||||||
if (redirectAfter) {
|
if (redirectAfter) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push('/admin/series-management')
|
router.push('/admin/series')
|
||||||
}, 1500)
|
}, 1500)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
205
app/pages/admin/tags/index.vue
Normal file
205
app/pages/admin/tags/index.vue
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
<template>
|
||||||
|
<div class="admin-tags">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Tags</h1>
|
||||||
|
<p>Review tag suggestions submitted by members. Approving a suggestion creates a tag in its pool.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="suggestions-list">
|
||||||
|
<div v-if="!suggestions.length" class="empty-state">
|
||||||
|
<p>No pending tag suggestions.</p>
|
||||||
|
<p class="empty-hint">New suggestions from members will appear here for review.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table v-else class="suggestions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tag</th>
|
||||||
|
<th>Pool</th>
|
||||||
|
<th>Suggested by</th>
|
||||||
|
<th>Suggested</th>
|
||||||
|
<th class="actions-col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="suggestion in suggestions" :key="suggestion.id">
|
||||||
|
<td class="label-cell">{{ suggestion.label }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="tag-pill">{{ poolLabel(suggestion.pool) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ suggestion.suggestedBy }}</td>
|
||||||
|
<td class="date-cell">{{ formatDate(suggestion.createdAt) }}</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<button
|
||||||
|
class="link-btn"
|
||||||
|
:disabled="processingId === suggestion.id"
|
||||||
|
@click="review(suggestion, 'approve')"
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="link-btn link-btn-danger"
|
||||||
|
:disabled="processingId === suggestion.id"
|
||||||
|
@click="review(suggestion, 'reject')"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin',
|
||||||
|
middleware: 'admin',
|
||||||
|
})
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const { data, refresh } = await useFetch('/api/admin/tag-suggestions')
|
||||||
|
|
||||||
|
const suggestions = computed(() => data.value?.suggestions || [])
|
||||||
|
|
||||||
|
const poolLabel = (pool) => (pool === 'cooperative' ? 'Cooperative' : 'Craft')
|
||||||
|
|
||||||
|
const formatDate = (value) =>
|
||||||
|
new Date(value).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
|
||||||
|
const processingId = ref(null)
|
||||||
|
|
||||||
|
const review = async (suggestion, action) => {
|
||||||
|
processingId.value = suggestion.id
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/admin/tag-suggestions/${suggestion.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { action },
|
||||||
|
})
|
||||||
|
toast.add({
|
||||||
|
title: action === 'approve' ? 'Tag approved' : 'Suggestion rejected',
|
||||||
|
description:
|
||||||
|
action === 'approve'
|
||||||
|
? `"${suggestion.label}" was added to the ${poolLabel(suggestion.pool)} pool.`
|
||||||
|
: `"${suggestion.label}" was rejected.`,
|
||||||
|
})
|
||||||
|
await refresh()
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({
|
||||||
|
title: 'Action failed',
|
||||||
|
description: error.data?.statusMessage || error.message,
|
||||||
|
color: 'red',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
processingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.page-header h1 {
|
||||||
|
font-family: 'Brygada 1918', serif;
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.page-header p {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions-list {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.suggestions-table th,
|
||||||
|
.suggestions-table td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.suggestions-table thead th {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-weight: normal;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.suggestions-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-cell {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.date-cell {
|
||||||
|
color: var(--text-faint);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'Commit Mono', monospace;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-col {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
.actions-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.link-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--candle-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.link-btn:hover {
|
||||||
|
color: var(--candle);
|
||||||
|
}
|
||||||
|
.link-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.link-btn-danger {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
.link-btn-danger:hover {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 40px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.empty-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1483
app/pages/join.vue
1483
app/pages/join.vue
File diff suppressed because it is too large
Load diff
|
|
@ -173,9 +173,12 @@
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
autofocus
|
autofocus
|
||||||
|
@blur="validateNewEmail"
|
||||||
|
@input="fieldErrors.email && (fieldErrors.email = '')"
|
||||||
@keydown.enter="handleUpdateEmail"
|
@keydown.enter="handleUpdateEmail"
|
||||||
@keydown.escape="cancelEmailEdit"
|
@keydown.escape="cancelEmailEdit"
|
||||||
>
|
>
|
||||||
|
<p v-if="fieldErrors.email" class="field-error">{{ fieldErrors.email }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="email-edit-actions">
|
<div class="email-edit-actions">
|
||||||
<button
|
<button
|
||||||
|
|
@ -245,35 +248,13 @@
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<div class="section-label">Change Contribution</div>
|
<div class="section-label">Change Contribution</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<ContributionAmountField
|
||||||
<label class="form-label" for="account-contribution">
|
v-model="form.contributionAmount"
|
||||||
Monthly Contribution
|
:cadence="cadence"
|
||||||
</label>
|
:allow-cadence-change="false"
|
||||||
<div class="contribution-input-row">
|
:show-summary="false"
|
||||||
<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 v-if="contributionChangeHint" class="tier-hint">
|
<div v-if="contributionChangeHint" class="tier-hint">
|
||||||
{{ contributionChangeHint }}
|
{{ contributionChangeHint }}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -314,7 +295,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { CONTRIBUTION_PRESETS, getGuidanceLabel, requiresPayment } from '~/config/contributions';
|
import { requiresPayment } from '~/config/contributions';
|
||||||
import { STATUS_LABELS } from '~/config/memberStatus';
|
import { STATUS_LABELS } from '~/config/memberStatus';
|
||||||
|
|
||||||
useSiteMeta({ title: 'Account', noindex: true });
|
useSiteMeta({ title: 'Account', noindex: true });
|
||||||
|
|
@ -339,6 +320,19 @@ const showEmailEdit = ref(false);
|
||||||
const newEmail = ref("");
|
const newEmail = ref("");
|
||||||
const isUpdatingEmail = ref(false);
|
const isUpdatingEmail = ref(false);
|
||||||
|
|
||||||
|
// Inline blur validation (UI feedback only — does not block submission)
|
||||||
|
const fieldErrors = reactive({ email: "" });
|
||||||
|
|
||||||
|
const validateNewEmail = () => {
|
||||||
|
const value = newEmail.value.trim();
|
||||||
|
if (!value) {
|
||||||
|
fieldErrors.email = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||||
|
fieldErrors.email = ok ? "" : "Please enter a valid email address.";
|
||||||
|
};
|
||||||
|
|
||||||
// Payment history state
|
// Payment history state
|
||||||
const paymentHistory = ref([]);
|
const paymentHistory = ref([]);
|
||||||
const paymentHistoryLoading = ref(false);
|
const paymentHistoryLoading = ref(false);
|
||||||
|
|
@ -373,14 +367,12 @@ const canChangeCard = computed(() => {
|
||||||
|
|
||||||
const cadence = computed(() => memberData.value?.billingCadence || 'monthly');
|
const cadence = computed(() => memberData.value?.billingCadence || 'monthly');
|
||||||
|
|
||||||
const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount));
|
|
||||||
|
|
||||||
const contributionChangeHint = computed(() => {
|
const contributionChangeHint = computed(() => {
|
||||||
const current = Number(memberData.value?.contributionAmount || 0);
|
const current = Number(memberData.value?.contributionAmount || 0);
|
||||||
const next = Number(form.contributionAmount || 0);
|
const next = Number(form.contributionAmount || 0);
|
||||||
if (current === next) return "";
|
if (current === next) return "";
|
||||||
if (current === 0 && next > 0) {
|
if (current === 0 && next > 0) {
|
||||||
const firstCharge = cadence.value === "annual" ? next * 12 : next;
|
const firstCharge = next;
|
||||||
return `You'll be charged $${firstCharge} today to start your subscription.`;
|
return `You'll be charged $${firstCharge} today to start your subscription.`;
|
||||||
}
|
}
|
||||||
if (current > 0 && next === 0) {
|
if (current > 0 && next === 0) {
|
||||||
|
|
@ -392,14 +384,13 @@ const contributionChangeHint = computed(() => {
|
||||||
const currentContributionLabel = computed(() => {
|
const currentContributionLabel = computed(() => {
|
||||||
const amount = Number(memberData.value?.contributionAmount || 0);
|
const amount = Number(memberData.value?.contributionAmount || 0);
|
||||||
if (!amount) return '$0';
|
if (!amount) return '$0';
|
||||||
const displayAmount = cadence.value === 'annual' ? amount * 12 : amount;
|
return cadence.value === 'annual' ? `$${amount} / year` : `$${amount} / month`;
|
||||||
return cadence.value === 'annual' ? `$${displayAmount} / year` : `$${displayAmount} / month`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const nextChargeAmount = computed(() => {
|
const nextChargeAmount = computed(() => {
|
||||||
const amount = Number(memberData.value?.contributionAmount || 0);
|
const amount = Number(memberData.value?.contributionAmount || 0);
|
||||||
if (!amount) return null;
|
if (!amount) return null;
|
||||||
return cadence.value === 'annual' ? amount * 12 : amount;
|
return amount;
|
||||||
});
|
});
|
||||||
|
|
||||||
const circleOptions = [
|
const circleOptions = [
|
||||||
|
|
@ -493,8 +484,14 @@ const handleUpdateContribution = async () => {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 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) {
|
||||||
|
// payment-setup.vue is monthly-only (always sends cadence: 'monthly') and
|
||||||
|
// form.contributionAmount is in cadence-unit. For annual members, convert
|
||||||
|
// to monthly-equivalent so we don't trigger an annual-sized monthly charge.
|
||||||
|
const monthlyTier = cadence.value === "annual"
|
||||||
|
? Math.floor(form.contributionAmount / 12)
|
||||||
|
: form.contributionAmount;
|
||||||
await navigateTo(
|
await navigateTo(
|
||||||
`/member/payment-setup?tier=${form.contributionAmount}&circle=${
|
`/member/payment-setup?tier=${monthlyTier}&circle=${
|
||||||
selectedCircle.value || memberData.value?.circle || 'community'
|
selectedCircle.value || memberData.value?.circle || 'community'
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
|
|
@ -535,6 +532,7 @@ const handleUpdateCircle = async () => {
|
||||||
const cancelEmailEdit = () => {
|
const cancelEmailEdit = () => {
|
||||||
showEmailEdit.value = false;
|
showEmailEdit.value = false;
|
||||||
newEmail.value = "";
|
newEmail.value = "";
|
||||||
|
fieldErrors.email = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateEmail = async () => {
|
const handleUpdateEmail = async () => {
|
||||||
|
|
@ -842,6 +840,11 @@ const confirmCancelMembership = async () => {
|
||||||
.email-edit .field input:focus {
|
.email-edit .field input:focus {
|
||||||
border-color: var(--candle);
|
border-color: var(--candle);
|
||||||
}
|
}
|
||||||
|
.email-edit .field .field-error {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ember);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
.email-edit-actions {
|
.email-edit-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
@ -890,52 +893,6 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
<PageHeader :title="welcomeTitle">
|
<PageHeader :title="welcomeTitle">
|
||||||
<div class="dashboard-meta">
|
<div class="dashboard-meta">
|
||||||
<CircleBadge :circle="memberData?.circle || 'community'" />
|
<CircleBadge :circle="memberData?.circle || 'community'" />
|
||||||
<span>${{ memberData?.contributionAmount ?? 0 }} CAD/mo</span>
|
<span>{{ formatContribution(memberData?.contributionAmount ?? 0, memberData?.billingCadence) }} CAD</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="showSlackComingNote" class="slack-coming-note">
|
<p v-if="showSlackComingNote" class="slack-coming-note">
|
||||||
Slack workspace access is part of your membership. Invitations are
|
Slack workspace access is part of your membership. Invitations are
|
||||||
|
|
@ -171,7 +171,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?.contributionAmount ?? 0 }} CAD/month</span
|
>{{ formatContribution(memberData?.contributionAmount ?? 0, memberData?.billingCadence) }} CAD</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="membership-row">
|
<div class="membership-row">
|
||||||
|
|
@ -220,6 +220,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { formatContribution } from '~/config/contributions';
|
||||||
|
|
||||||
useSiteMeta({ title: 'Dashboard', noindex: true });
|
useSiteMeta({ title: 'Dashboard', noindex: true });
|
||||||
|
|
||||||
const { memberData, checkMemberStatus } = useAuth();
|
const { memberData, checkMemberStatus } = useAuth();
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,13 @@ const formatters = {
|
||||||
text: `Changed circle from ${circleLabel(m.from)} to ${circleLabel(m.to)}`,
|
text: `Changed circle from ${circleLabel(m.from)} to ${circleLabel(m.to)}`,
|
||||||
icon: 'i-lucide-refresh-cw'
|
icon: 'i-lucide-refresh-cw'
|
||||||
}),
|
}),
|
||||||
contribution_changed: (m) => ({
|
contribution_changed: (m) => {
|
||||||
text: `Changed contribution from $${m.from}/mo to $${m.to}/mo`,
|
const suffix = m.cadence === 'annual' ? '/yr' : '/mo'
|
||||||
|
return {
|
||||||
|
text: `Changed contribution from $${m.from}${suffix} to $${m.to}${suffix}`,
|
||||||
icon: 'i-lucide-coins'
|
icon: 'i-lucide-coins'
|
||||||
}),
|
}
|
||||||
|
},
|
||||||
email_changed: (m) => ({
|
email_changed: (m) => ({
|
||||||
text: `Changed email address`,
|
text: `Changed email address`,
|
||||||
icon: 'i-lucide-mail'
|
icon: 'i-lucide-mail'
|
||||||
|
|
@ -41,10 +44,13 @@ const formatters = {
|
||||||
: 'Updated profile',
|
: 'Updated profile',
|
||||||
icon: 'i-lucide-user-pen'
|
icon: 'i-lucide-user-pen'
|
||||||
}),
|
}),
|
||||||
subscription_created: (m) => ({
|
subscription_created: (m) => {
|
||||||
text: m.amount != null ? `Started $${m.amount}/mo subscription` : 'Started subscription',
|
const suffix = m.cadence === 'annual' ? '/yr' : '/mo'
|
||||||
|
return {
|
||||||
|
text: m.amount != null ? `Started $${m.amount}${suffix} subscription` : 'Started subscription',
|
||||||
icon: 'i-lucide-credit-card'
|
icon: 'i-lucide-credit-card'
|
||||||
}),
|
}
|
||||||
|
},
|
||||||
subscription_cancelled: () => ({
|
subscription_cancelled: () => ({
|
||||||
text: 'Cancelled subscription',
|
text: 'Cancelled subscription',
|
||||||
icon: 'i-lucide-credit-card'
|
icon: 'i-lucide-credit-card'
|
||||||
|
|
|
||||||
|
|
@ -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/)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ test.describe('Admin events CRUD', () => {
|
||||||
.fill('e2e test event description')
|
.fill('e2e test event description')
|
||||||
|
|
||||||
await adminPage
|
await adminPage
|
||||||
.getByPlaceholder('e.g., https://zoom.us/j/123... or #channel-name')
|
.getByPlaceholder('e.g., https://zoom.us/j/123..., #channel-name, or TBD')
|
||||||
.fill('https://example.com/zoom')
|
.fill('https://example.com/zoom')
|
||||||
|
|
||||||
const startInput = adminPage.getByPlaceholder(
|
const startInput = adminPage.getByPlaceholder(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { test, expect } from './helpers/fixtures.js'
|
||||||
|
|
||||||
test.describe('Admin series management page', () => {
|
test.describe('Admin series management page', () => {
|
||||||
test('series list loads for admin', async ({ adminPage }) => {
|
test('series list loads for admin', async ({ adminPage }) => {
|
||||||
await adminPage.goto('/admin/series-management')
|
await adminPage.goto('/admin/series')
|
||||||
await expect(adminPage.getByRole('heading', { name: 'Series', level: 1 })).toBeVisible({
|
await expect(adminPage.getByRole('heading', { name: 'Series', level: 1 })).toBeVisible({
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
})
|
})
|
||||||
|
|
@ -12,9 +12,9 @@ test.describe('Admin series management page', () => {
|
||||||
|
|
||||||
test.describe('Admin series access control', () => {
|
test.describe('Admin series access control', () => {
|
||||||
test('non-admin redirect', async ({ page }) => {
|
test('non-admin redirect', async ({ page }) => {
|
||||||
await page.goto('/admin/series-management')
|
await page.goto('/admin/series')
|
||||||
await page.waitForURL((url) => !url.pathname.startsWith('/admin'))
|
await page.waitForURL((url) => !url.pathname.startsWith('/admin'))
|
||||||
expect(page.url()).not.toContain('/admin/series-management')
|
expect(page.url()).not.toContain('/admin/series')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@ test.describe('Admin series CRUD', () => {
|
||||||
|
|
||||||
await adminPage.getByRole('button', { name: 'Create Series' }).click()
|
await adminPage.getByRole('button', { name: 'Create Series' }).click()
|
||||||
|
|
||||||
await adminPage.waitForURL('**/admin/series-management', { timeout: 15000 })
|
await adminPage.waitForURL('**/admin/series', { timeout: 15000 })
|
||||||
|
|
||||||
const card = adminPage.locator('.series-card', { hasText: title })
|
const card = adminPage.locator('.series-card', { hasText: title })
|
||||||
await expect(card).toBeVisible({ timeout: 10000 })
|
await expect(card).toBeVisible({ timeout: 10000 })
|
||||||
|
|
|
||||||
57
e2e/admin-tags.spec.js
Normal file
57
e2e/admin-tags.spec.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { test, expect } from './helpers/fixtures.js'
|
||||||
|
|
||||||
|
test.describe('Admin tags page', () => {
|
||||||
|
test('page loads for admin', async ({ adminPage }) => {
|
||||||
|
await adminPage.goto('/admin/tags')
|
||||||
|
await expect(adminPage.getByRole('heading', { name: 'Tags', level: 1 })).toBeVisible({
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('approve and reject pending suggestions', async ({ adminPage }) => {
|
||||||
|
const suffix = Date.now().toString().slice(-6)
|
||||||
|
const approveLabel = `e2e-tag-approve-${suffix}`
|
||||||
|
const rejectLabel = `e2e-tag-reject-${suffix}`
|
||||||
|
|
||||||
|
// Load the page first so the csrf-token cookie is set, then seed two
|
||||||
|
// pending suggestions via the authed member endpoint (state-changing
|
||||||
|
// requests require the double-submit CSRF header — see middleware/01.csrf.js).
|
||||||
|
await adminPage.goto('/admin/tags')
|
||||||
|
const cookies = await adminPage.context().cookies()
|
||||||
|
const csrf = cookies.find((c) => c.name === 'csrf-token')?.value
|
||||||
|
|
||||||
|
await adminPage.request.post('/api/tags/suggest', {
|
||||||
|
headers: { 'x-csrf-token': csrf },
|
||||||
|
data: { label: approveLabel, pool: 'craft' },
|
||||||
|
})
|
||||||
|
await adminPage.request.post('/api/tags/suggest', {
|
||||||
|
headers: { 'x-csrf-token': csrf },
|
||||||
|
data: { label: rejectLabel, pool: 'cooperative' },
|
||||||
|
})
|
||||||
|
|
||||||
|
await adminPage.reload()
|
||||||
|
await adminPage.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
const approveRow = adminPage.locator('tr', { hasText: approveLabel })
|
||||||
|
await expect(approveRow).toBeVisible({ timeout: 10000 })
|
||||||
|
await approveRow.getByRole('button', { name: 'Approve' }).click()
|
||||||
|
await expect(adminPage.locator('tr', { hasText: approveLabel })).toHaveCount(0, {
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const rejectRow = adminPage.locator('tr', { hasText: rejectLabel })
|
||||||
|
await expect(rejectRow).toBeVisible()
|
||||||
|
await rejectRow.getByRole('button', { name: 'Reject' }).click()
|
||||||
|
await expect(adminPage.locator('tr', { hasText: rejectLabel })).toHaveCount(0, {
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Admin tags access control', () => {
|
||||||
|
test('non-admin redirect', async ({ page }) => {
|
||||||
|
await page.goto('/admin/tags')
|
||||||
|
await page.waitForURL((url) => !url.pathname.startsWith('/admin'))
|
||||||
|
expect(page.url()).not.toContain('/admin/tags')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -11,7 +11,7 @@ test.describe('Events list page', () => {
|
||||||
const filterBar = page.locator('.filter-bar')
|
const filterBar = page.locator('.filter-bar')
|
||||||
await expect(filterBar).toBeVisible()
|
await expect(filterBar).toBeVisible()
|
||||||
|
|
||||||
for (const label of ['All', 'Workshops', 'Community', 'Social', 'Showcase']) {
|
for (const label of ['All', 'Talk / Presentation', 'Workshop', 'Community Meetup', 'Co-working Session', 'Peer Session', 'Skills Share', 'Info Session']) {
|
||||||
await expect(filterBar.locator('button', { hasText: label })).toBeVisible()
|
await expect(filterBar.locator('button', { hasText: label })).toBeVisible()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -36,7 +36,7 @@ test.describe('Events list page', () => {
|
||||||
// Wait for Vue hydration — the "All" filter should have the active class once reactive
|
// Wait for Vue hydration — the "All" filter should have the active class once reactive
|
||||||
const allBtn = page.locator('.filter-btn', { hasText: 'All' })
|
const allBtn = page.locator('.filter-btn', { hasText: 'All' })
|
||||||
await expect(allBtn).toHaveClass(/active/, { timeout: 10000 })
|
await expect(allBtn).toHaveClass(/active/, { timeout: 10000 })
|
||||||
const workshopsBtn = page.locator('.filter-bar button', { hasText: 'Workshops' })
|
const workshopsBtn = page.locator('.filter-bar button', { hasText: 'Workshop' })
|
||||||
await workshopsBtn.click()
|
await workshopsBtn.click()
|
||||||
await expect(workshopsBtn).toHaveClass(/active/, { timeout: 5000 })
|
await expect(workshopsBtn).toHaveClass(/active/, { timeout: 5000 })
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
// Mock Helcim API responses for join flow (avoids dependency on external API)
|
// Mock Helcim API responses for join flow (avoids dependency on external API)
|
||||||
function mockHelcimAPIs(page, { failCustomer = false } = {}) {
|
function mockHelcimAPIs(page, { failCustomer = false } = {}) {
|
||||||
// Mock Helcim customer creation
|
|
||||||
page.route('**/api/helcim/customer', async (route) => {
|
page.route('**/api/helcim/customer', async (route) => {
|
||||||
if (failCustomer) {
|
if (failCustomer) {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
|
|
@ -26,7 +25,6 @@ function mockHelcimAPIs(page, { failCustomer = false } = {}) {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mock subscription creation
|
|
||||||
page.route('**/api/helcim/subscription', async (route) => {
|
page.route('**/api/helcim/subscription', async (route) => {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|
@ -46,35 +44,35 @@ test.describe('Join page — member signup flow', () => {
|
||||||
|
|
||||||
await expect(page.locator('#join-name')).toBeVisible()
|
await expect(page.locator('#join-name')).toBeVisible()
|
||||||
await expect(page.locator('#join-email')).toBeVisible()
|
await expect(page.locator('#join-email')).toBeVisible()
|
||||||
await expect(page.locator('#circle-community')).toBeAttached()
|
await expect(page.locator('#pwyc-0')).toBeAttached()
|
||||||
await expect(page.locator('#circle-founder')).toBeAttached()
|
await expect(page.locator('#pwyc-15')).toBeAttached()
|
||||||
await expect(page.locator('#circle-practitioner')).toBeAttached()
|
await expect(page.locator('#pwyc-50')).toBeAttached()
|
||||||
await expect(page.locator('#join-contribution')).toBeVisible()
|
await expect(page.getByTestId('cadence-monthly')).toBeVisible()
|
||||||
await expect(page.locator('.form-submit')).toBeVisible()
|
await expect(page.getByTestId('cadence-annual')).toBeVisible()
|
||||||
|
await expect(page.locator('.submit-btn')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('submit button disabled when form incomplete', async ({ page }) => {
|
test('submit button disabled when form incomplete', async ({ page }) => {
|
||||||
await page.goto('/join')
|
await page.goto('/join')
|
||||||
await page.waitForLoadState('networkidle')
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
// Clear name and email — circle defaults to community, contribution defaults to $15
|
// Clear name and email — contribution defaults to $15
|
||||||
await page.locator('#join-name').fill('')
|
await page.locator('#join-name').fill('')
|
||||||
await page.locator('#join-email').fill('')
|
await page.locator('#join-email').fill('')
|
||||||
|
|
||||||
// Button should be disabled with empty required fields
|
await expect(page.locator('.submit-btn')).toBeDisabled()
|
||||||
await expect(page.locator('.form-submit')).toBeDisabled()
|
|
||||||
|
|
||||||
// Fill only name — still incomplete
|
// Fill only name — still incomplete
|
||||||
await page.locator('#join-name').fill('Test User')
|
await page.locator('#join-name').fill('Test User')
|
||||||
await expect(page.locator('.form-submit')).toBeDisabled()
|
await expect(page.locator('.submit-btn')).toBeDisabled()
|
||||||
|
|
||||||
// Fill email — agreement still unchecked, so still disabled
|
// Fill email — agreement still unchecked, so still disabled
|
||||||
await page.locator('#join-email').fill('incomplete-test@example.com')
|
await page.locator('#join-email').fill('incomplete-test@example.com')
|
||||||
await expect(page.locator('.form-submit')).toBeDisabled()
|
await expect(page.locator('.submit-btn')).toBeDisabled()
|
||||||
|
|
||||||
// Check the Community Guidelines agreement — now all required fields satisfied
|
// Check the Community Guidelines agreement — now all required fields satisfied
|
||||||
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('.submit-btn')).toBeEnabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('fill and submit free tier', async ({ page }) => {
|
test('fill and submit free tier', async ({ page }) => {
|
||||||
|
|
@ -83,20 +81,17 @@ test.describe('Join page — member signup flow', () => {
|
||||||
await page.goto('/join')
|
await page.goto('/join')
|
||||||
await page.waitForLoadState('networkidle')
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
// Fill in the form
|
|
||||||
await page.locator('#join-name').fill('E2E Test User')
|
await page.locator('#join-name').fill('E2E Test User')
|
||||||
await page.locator('#join-email').fill(uniqueEmail)
|
await page.locator('#join-email').fill(uniqueEmail)
|
||||||
await page.locator('#circle-community').check({ force: true })
|
// Pick the $0 preset
|
||||||
// Contribution is now a numeric input with preset chips, not a select
|
await page.locator('label[for="pwyc-0"]').click()
|
||||||
await page.locator('#join-contribution').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('.submit-btn')).toBeEnabled()
|
||||||
|
|
||||||
// Mock Helcim APIs before submitting
|
|
||||||
await mockHelcimAPIs(page)
|
await mockHelcimAPIs(page)
|
||||||
|
|
||||||
await page.locator('.form-submit').click()
|
await page.locator('.submit-btn').click()
|
||||||
|
|
||||||
// Free tier flips the SignupFlowOverlay into its success state
|
// Free tier flips the SignupFlowOverlay into its success state
|
||||||
await expect(
|
await expect(
|
||||||
|
|
@ -104,42 +99,26 @@ test.describe('Join page — member signup flow', () => {
|
||||||
).toBeVisible({ timeout: 15000 })
|
).toBeVisible({ timeout: 15000 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('cadence toggle updates billing summary to annual ×12', async ({ page }) => {
|
test('cadence toggle multiplies preset row amounts by 12', async ({ page }) => {
|
||||||
await page.goto('/join')
|
await page.goto('/join')
|
||||||
await page.waitForLoadState('networkidle')
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
await page.locator('#join-contribution').fill('10')
|
// Default is Monthly: the $15 row reads "$15"
|
||||||
await page.locator('label[for="cadence-annual"]').click()
|
const suggestedRow = page.locator('#pwyc-15 + .pwyc-row-content .pwyc-amt')
|
||||||
|
await expect(suggestedRow).toHaveText('$15')
|
||||||
|
|
||||||
const summary = page.locator('.billing-summary')
|
// Switch to Annual: same row now reads "$180"
|
||||||
await expect(summary).toBeVisible()
|
await page.getByTestId('cadence-annual').click()
|
||||||
await expect(summary).toContainText('$120 today')
|
await expect(suggestedRow).toHaveText('$180')
|
||||||
await expect(summary).toContainText('$10/month × 12')
|
|
||||||
await expect(summary).toContainText('$120 every year')
|
|
||||||
|
|
||||||
await page.locator('label[for="cadence-monthly"]').click()
|
// Switch back to Monthly: returns to "$15"
|
||||||
await expect(summary).toContainText('$10 today')
|
await page.getByTestId('cadence-monthly').click()
|
||||||
await expect(summary).toContainText('$10 every month')
|
await expect(suggestedRow).toHaveText('$15')
|
||||||
})
|
|
||||||
|
|
||||||
test('contribution guidance label changes with amount tier', async ({ page }) => {
|
|
||||||
await page.goto('/join')
|
|
||||||
await page.waitForLoadState('networkidle')
|
|
||||||
|
|
||||||
const guidance = page.locator('.contribution-guidance')
|
|
||||||
|
|
||||||
await page.locator('#join-contribution').fill('5')
|
|
||||||
await expect(guidance).toHaveText(/I can contribute/)
|
|
||||||
|
|
||||||
await page.locator('#join-contribution').fill('30')
|
|
||||||
await expect(guidance).toHaveText(/I can support others too/)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('paid tier flow reaches success state with HelcimPay stubbed', async ({ page }) => {
|
test('paid tier flow reaches success state with HelcimPay stubbed', async ({ page }) => {
|
||||||
const uniqueEmail = `test-e2e-paid-${Date.now()}@example.com`
|
const uniqueEmail = `test-e2e-paid-${Date.now()}@example.com`
|
||||||
|
|
||||||
// Stub HelcimPay window globals before the page loads so the composable's
|
|
||||||
// script-load path is bypassed and we resolve verifyPayment synchronously.
|
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
window.appendHelcimPayIframe = (checkoutToken) => {
|
window.appendHelcimPayIframe = (checkoutToken) => {
|
||||||
const eventName = 'helcim-pay-js-' + checkoutToken
|
const eventName = 'helcim-pay-js-' + checkoutToken
|
||||||
|
|
@ -190,12 +169,12 @@ test.describe('Join page — member signup flow', () => {
|
||||||
|
|
||||||
await page.locator('#join-name').fill('Paid E2E User')
|
await page.locator('#join-name').fill('Paid E2E User')
|
||||||
await page.locator('#join-email').fill(uniqueEmail)
|
await page.locator('#join-email').fill(uniqueEmail)
|
||||||
await page.locator('#circle-community').check({ force: true })
|
// $15 preset is selected by default — verify and submit
|
||||||
await page.locator('#join-contribution').fill('15')
|
await page.locator('#pwyc-15').check({ force: true })
|
||||||
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('.submit-btn')).toBeEnabled()
|
||||||
await page.locator('.form-submit').click()
|
await page.locator('.submit-btn').click()
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('heading', { name: 'Welcome to Ghost Guild!' })
|
page.getByRole('heading', { name: 'Welcome to Ghost Guild!' })
|
||||||
|
|
@ -208,15 +187,13 @@ test.describe('Join page — member signup flow', () => {
|
||||||
await page.goto('/join')
|
await page.goto('/join')
|
||||||
await page.waitForLoadState('networkidle')
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
// Mock customer endpoint to return 409 (email already exists)
|
|
||||||
await mockHelcimAPIs(page, { failCustomer: true })
|
await mockHelcimAPIs(page, { failCustomer: true })
|
||||||
|
|
||||||
await page.locator('#join-name').fill('Dup Test User')
|
await page.locator('#join-name').fill('Dup Test User')
|
||||||
await page.locator('#join-email').fill(duplicateEmail)
|
await page.locator('#join-email').fill(duplicateEmail)
|
||||||
await page.locator('#circle-community').check({ force: true })
|
await page.locator('label[for="pwyc-0"]').click()
|
||||||
await page.locator('#join-contribution').fill('0')
|
|
||||||
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
|
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
|
||||||
await page.locator('.form-submit').click()
|
await page.locator('.submit-btn').click()
|
||||||
|
|
||||||
// Helcim 409 puts SignupFlowOverlay into its error state
|
// Helcim 409 puts SignupFlowOverlay into its error state
|
||||||
const overlayError = page.locator('.signup-flow-overlay .error-box')
|
const overlayError = page.locator('.signup-flow-overlay .error-box')
|
||||||
|
|
|
||||||
74
scripts/helcim-tunnel.sh
Executable file
74
scripts/helcim-tunnel.sh
Executable file
|
|
@ -0,0 +1,74 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Public tunnel for testing the Helcim payment flow locally.
|
||||||
|
#
|
||||||
|
# Why a production build (not `nuxt dev`): dev mode serves the client as 100+
|
||||||
|
# individual ESM modules, which a cloudflared quick tunnel delivers unreliably
|
||||||
|
# (sporadic 503s on the page-load burst abort hydration — the page renders but
|
||||||
|
# is dead to clicks). A production bundle is a handful of hashed assets, served
|
||||||
|
# rock-solid through the tunnel.
|
||||||
|
#
|
||||||
|
# BASE_URL + NUXT_PUBLIC_APP_URL are set to the tunnel URL at launch — the Helcim
|
||||||
|
# signup route requires the request Origin to exactly match BASE_URL. All other
|
||||||
|
# secrets load from .env via Node's --env-file (shell-set vars take precedence,
|
||||||
|
# so the tunnel URL wins). .env itself is never modified.
|
||||||
|
#
|
||||||
|
# Trade-off: no HMR. Re-run this script after code changes to rebuild.
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/helcim-tunnel.sh (Ctrl-C stops the server and the tunnel)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "ERROR: .env not found in $(pwd)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
LOG="$(mktemp)"
|
||||||
|
CF_PID=""
|
||||||
|
SRV_PID=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
echo ""
|
||||||
|
echo "Stopping server and tunnel..."
|
||||||
|
[ -n "$SRV_PID" ] && kill "$SRV_PID" 2>/dev/null || true
|
||||||
|
[ -n "$CF_PID" ] && kill "$CF_PID" 2>/dev/null || true
|
||||||
|
rm -f "$LOG"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT TERM
|
||||||
|
|
||||||
|
echo "Starting cloudflared quick tunnel -> http://localhost:3000 ..."
|
||||||
|
cloudflared tunnel --url http://localhost:3000 >"$LOG" 2>&1 &
|
||||||
|
CF_PID=$!
|
||||||
|
|
||||||
|
TUNNEL_URL=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
TUNNEL_URL="$(grep -oE 'https://[a-zA-Z0-9.-]+\.trycloudflare\.com' "$LOG" | head -1 || true)"
|
||||||
|
[ -n "$TUNNEL_URL" ] && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$TUNNEL_URL" ]; then
|
||||||
|
echo "ERROR: could not obtain a tunnel URL. cloudflared output:" >&2
|
||||||
|
cat "$LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Tunnel live: $TUNNEL_URL"
|
||||||
|
|
||||||
|
echo "Building production bundle (npm run build)..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " Serving .output through the tunnel."
|
||||||
|
echo " Open the app at: $TUNNEL_URL (NOT localhost — Helcim origin check requires it)"
|
||||||
|
echo " Ctrl-C stops the server and the tunnel."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
PORT=3000 \
|
||||||
|
HOST=127.0.0.1 \
|
||||||
|
BASE_URL="$TUNNEL_URL" \
|
||||||
|
NUXT_PUBLIC_APP_URL="$TUNNEL_URL" \
|
||||||
|
node --env-file=.env .output/server/index.mjs &
|
||||||
|
SRV_PID=$!
|
||||||
|
wait "$SRV_PID"
|
||||||
112
scripts/migrate-annual-contribution-to-cadence-unit.cjs
Normal file
112
scripts/migrate-annual-contribution-to-cadence-unit.cjs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
/**
|
||||||
|
* One-time migration: convert Member.contributionAmount from monthly-equivalent
|
||||||
|
* to cadence-unit for billingCadence='annual' members. After this migration,
|
||||||
|
* an annual member with contributionAmount=180 means "$180/year" (vs the old
|
||||||
|
* interpretation "$15/month × 12 = $180/year").
|
||||||
|
*
|
||||||
|
* Companion to commit 5023fb1 which dropped × 12 from the server's recurringAmount
|
||||||
|
* computation. Run AFTER deploying the new server code, BEFORE annual members
|
||||||
|
* try to renew or update.
|
||||||
|
*
|
||||||
|
* IDEMPOTENT via a transient marker field (`contributionAmountConverted: true`).
|
||||||
|
* The script only acts on rows where this flag is not set, so re-running is
|
||||||
|
* safe — the second run will find 0 unconverted rows.
|
||||||
|
*
|
||||||
|
* Trade-off: this pollutes the schema with a transient field. A follow-up
|
||||||
|
* migration can $unset `contributionAmountConverted` from all docs once every
|
||||||
|
* environment is confirmed migrated. The field is harmless if left in place.
|
||||||
|
* (Option A from Task 9's plan — chosen because no migration-tracking
|
||||||
|
* collection exists in this codebase.)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/migrate-annual-contribution-to-cadence-unit.cjs # dry-run
|
||||||
|
* node scripts/migrate-annual-contribution-to-cadence-unit.cjs --apply # writes
|
||||||
|
*/
|
||||||
|
require('dotenv').config()
|
||||||
|
const mongoose = require('mongoose')
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const apply = process.argv.includes('--apply')
|
||||||
|
if (!process.env.MONGODB_URI) {
|
||||||
|
console.error('MONGODB_URI not set in environment')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
await mongoose.connect(process.env.MONGODB_URI)
|
||||||
|
const db = mongoose.connection.db
|
||||||
|
const col = db.collection('members')
|
||||||
|
|
||||||
|
// Find annual members that haven't been converted yet.
|
||||||
|
const filter = {
|
||||||
|
billingCadence: 'annual',
|
||||||
|
contributionAmountConverted: { $ne: true },
|
||||||
|
}
|
||||||
|
const legacyCount = await col.countDocuments(filter)
|
||||||
|
console.log(`Found ${legacyCount} annual members not yet converted`)
|
||||||
|
|
||||||
|
if (legacyCount === 0) {
|
||||||
|
console.log('Nothing to migrate.')
|
||||||
|
await mongoose.disconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursor = col.find(filter)
|
||||||
|
let updated = 0
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
while (await cursor.hasNext()) {
|
||||||
|
const doc = await cursor.next()
|
||||||
|
const current = doc.contributionAmount
|
||||||
|
|
||||||
|
if (current === null || current === undefined) {
|
||||||
|
console.warn(` SKIP ${doc._id}: contributionAmount is ${current}`)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(current) || current < 0) {
|
||||||
|
console.warn(
|
||||||
|
` SKIP ${doc._id}: contributionAmount=${JSON.stringify(current)} is not a non-negative integer`,
|
||||||
|
)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = current * 12
|
||||||
|
|
||||||
|
if (current === 0) {
|
||||||
|
console.log(` ${doc._id}: contributionAmount=0, no change (still 0 after ×12)`)
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
` ${apply ? 'Update' : 'Would update'} ${doc._id}: contributionAmount ${current} → ${next}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apply) {
|
||||||
|
await col.updateOne(
|
||||||
|
{ _id: doc._id },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
contributionAmount: next,
|
||||||
|
contributionAmountConverted: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\n${apply ? 'Updated' : 'Would update'} ${updated} documents, skipped ${skipped}`,
|
||||||
|
)
|
||||||
|
if (!apply) console.log('Dry-run complete. Re-run with --apply to write changes.')
|
||||||
|
await mongoose.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(async (err) => {
|
||||||
|
console.error(err)
|
||||||
|
try {
|
||||||
|
await mongoose.disconnect()
|
||||||
|
} catch {}
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|
@ -16,10 +16,14 @@ export default defineEventHandler(async (event) => {
|
||||||
endDate: { $gte: now }
|
endDate: { $gte: now }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Calculate monthly revenue from member contributions
|
// Calculate monthly revenue from member contributions.
|
||||||
const members = await Member.find({}, 'contributionAmount').lean()
|
// contributionAmount is stored in cadence units (monthly = $/mo, annual = $/yr),
|
||||||
|
// so normalize annual amounts to monthly equivalents before summing.
|
||||||
|
const members = await Member.find({}, 'contributionAmount billingCadence').lean()
|
||||||
const monthlyRevenue = members.reduce((total, member) => {
|
const monthlyRevenue = members.reduce((total, member) => {
|
||||||
return total + (member.contributionAmount || 0)
|
const amt = member.contributionAmount || 0
|
||||||
|
const monthlyEquivalent = member.billingCadence === 'annual' ? amt / 12 : amt
|
||||||
|
return total + monthlyEquivalent
|
||||||
}, 0)
|
}, 0)
|
||||||
|
|
||||||
const pendingSlackInvites = await Member.countDocuments({ slackInvited: false })
|
const pendingSlackInvites = await Member.countDocuments({ slackInvited: false })
|
||||||
|
|
@ -42,7 +46,7 @@ export default defineEventHandler(async (event) => {
|
||||||
stats: {
|
stats: {
|
||||||
totalMembers,
|
totalMembers,
|
||||||
activeEvents,
|
activeEvents,
|
||||||
monthlyRevenue,
|
monthlyRevenue: Math.round(monthlyRevenue),
|
||||||
pendingSlackInvites
|
pendingSlackInvites
|
||||||
},
|
},
|
||||||
recentMembers,
|
recentMembers,
|
||||||
|
|
|
||||||
43
server/api/admin/tag-suggestions/[id].patch.js
Normal file
43
server/api/admin/tag-suggestions/[id].patch.js
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import TagSuggestion from '../../../models/tagSuggestion.js'
|
||||||
|
import Tag from '../../../models/tag.js'
|
||||||
|
import { connectDB } from '../../../utils/mongoose.js'
|
||||||
|
import { requireAdmin } from '../../../utils/auth.js'
|
||||||
|
import { validateBody } from '../../../utils/validateBody.js'
|
||||||
|
import { tagSuggestionReviewSchema } from '../../../utils/schemas.js'
|
||||||
|
|
||||||
|
const slugify = (s) =>
|
||||||
|
s
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/[^a-z0-9-]/g, '')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdmin(event)
|
||||||
|
await connectDB()
|
||||||
|
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
const { action } = await validateBody(event, tagSuggestionReviewSchema)
|
||||||
|
|
||||||
|
const suggestion = await TagSuggestion.findById(id).lean()
|
||||||
|
if (!suggestion) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Tag suggestion not found' })
|
||||||
|
}
|
||||||
|
if (suggestion.status !== 'pending') {
|
||||||
|
throw createError({ statusCode: 409, statusMessage: 'Tag suggestion already reviewed' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'approve') {
|
||||||
|
const slug = slugify(suggestion.label)
|
||||||
|
if (slug && !(await Tag.findOne({ slug }))) {
|
||||||
|
await Tag.create({ slug, label: suggestion.label, pool: suggestion.pool, active: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = action === 'approve' ? 'approved' : 'rejected'
|
||||||
|
await TagSuggestion.findByIdAndUpdate(id, { status }, { runValidators: false })
|
||||||
|
|
||||||
|
return { suggestion: { id, status } }
|
||||||
|
})
|
||||||
23
server/api/admin/tag-suggestions/index.get.js
Normal file
23
server/api/admin/tag-suggestions/index.get.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import TagSuggestion from '../../../models/tagSuggestion.js'
|
||||||
|
import { connectDB } from '../../../utils/mongoose.js'
|
||||||
|
import { requireAdmin } from '../../../utils/auth.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdmin(event)
|
||||||
|
await connectDB()
|
||||||
|
|
||||||
|
const suggestions = await TagSuggestion.find({ status: 'pending' })
|
||||||
|
.sort({ createdAt: 1 })
|
||||||
|
.populate('suggestedBy', 'name')
|
||||||
|
.lean()
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestions: suggestions.map((s) => ({
|
||||||
|
id: String(s._id),
|
||||||
|
label: s.label,
|
||||||
|
pool: s.pool,
|
||||||
|
suggestedBy: s.suggestedBy?.name || 'Unknown',
|
||||||
|
createdAt: s.createdAt
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -36,7 +36,7 @@ export default defineEventHandler(async (event) => {
|
||||||
const isFirstActivation = preMember?.status === 'pending_payment'
|
const isFirstActivation = preMember?.status === 'pending_payment'
|
||||||
const member = await Member.findById(preMember._id)
|
const member = await Member.findById(preMember._id)
|
||||||
|
|
||||||
logActivity(member._id, 'subscription_created', { amount: body.contributionAmount })
|
logActivity(member._id, 'subscription_created', { amount: body.contributionAmount, cadence: body.cadence })
|
||||||
|
|
||||||
await autoFlagPreExistingSlackAccess(member)
|
await autoFlagPreExistingSlackAccess(member)
|
||||||
try {
|
try {
|
||||||
|
|
@ -47,6 +47,7 @@ export default defineEventHandler(async (event) => {
|
||||||
member.email,
|
member.email,
|
||||||
member.circle,
|
member.circle,
|
||||||
member.contributionAmount,
|
member.contributionAmount,
|
||||||
|
member.billingCadence,
|
||||||
'manual_invitation_required'
|
'manual_invitation_required'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -79,9 +80,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
const cadence = body.cadence
|
const cadence = body.cadence
|
||||||
const paymentPlanId = getHelcimPlanId(cadence)
|
const paymentPlanId = getHelcimPlanId(cadence)
|
||||||
const recurringAmount = cadence === 'annual'
|
const recurringAmount = body.contributionAmount
|
||||||
? body.contributionAmount * 12
|
|
||||||
: body.contributionAmount
|
|
||||||
|
|
||||||
if (!paymentPlanId) {
|
if (!paymentPlanId) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|
@ -135,7 +134,7 @@ export default defineEventHandler(async (event) => {
|
||||||
const isFirstActivation = preMember?.status === 'pending_payment'
|
const isFirstActivation = preMember?.status === 'pending_payment'
|
||||||
const member = await Member.findById(preMember._id)
|
const member = await Member.findById(preMember._id)
|
||||||
|
|
||||||
logActivity(member._id, 'subscription_created', { amount: body.contributionAmount })
|
logActivity(member._id, 'subscription_created', { amount: body.contributionAmount, cadence: body.cadence })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const txs = await listHelcimCustomerTransactions(body.customerCode)
|
const txs = await listHelcimCustomerTransactions(body.customerCode)
|
||||||
|
|
@ -159,6 +158,7 @@ export default defineEventHandler(async (event) => {
|
||||||
member.email,
|
member.email,
|
||||||
member.circle,
|
member.circle,
|
||||||
member.contributionAmount,
|
member.contributionAmount,
|
||||||
|
member.billingCadence,
|
||||||
'manual_invitation_required'
|
'manual_invitation_required'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
// Verify payment token from HelcimPay.js
|
// Verify payment token from HelcimPay.js
|
||||||
import { requireAuth } from '../../utils/auth.js'
|
import { requireAuth, getSignupBridgeMember } from '../../utils/auth.js'
|
||||||
import { validateBody } from '../../utils/validateBody.js'
|
import { validateBody } from '../../utils/validateBody.js'
|
||||||
import { paymentVerifySchema } from '../../utils/schemas.js'
|
import { paymentVerifySchema } from '../../utils/schemas.js'
|
||||||
import { listHelcimCustomerCards } from '../../utils/helcim.js'
|
import { listHelcimCustomerCards } from '../../utils/helcim.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
|
// Membership signup verifies the card before email verify; allow the
|
||||||
|
// signup-bridge cookie set by /api/helcim/customer to satisfy auth here.
|
||||||
|
const bridgeMember = await getSignupBridgeMember(event)
|
||||||
|
if (!bridgeMember) {
|
||||||
await requireAuth(event)
|
await requireAuth(event)
|
||||||
|
}
|
||||||
const body = await validateBody(event, paymentVerifySchema)
|
const body = await validateBody(event, paymentVerifySchema)
|
||||||
|
|
||||||
// Verify the card token by fetching the customer's cards from Helcim
|
// Verify the card token by fetching the customer's cards from Helcim
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export default defineEventHandler(async (event) => {
|
||||||
member.email,
|
member.email,
|
||||||
member.circle,
|
member.circle,
|
||||||
member.contributionAmount,
|
member.contributionAmount,
|
||||||
|
member.billingCadence,
|
||||||
'manual_invitation_required'
|
'manual_invitation_required'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
// Log contribution change (fire-and-forget, at the top so it logs regardless of which case path executes)
|
// Log contribution change (fire-and-forget, at the top so it logs regardless of which case path executes)
|
||||||
const logContributionChange = () => {
|
const logContributionChange = () => {
|
||||||
logActivity(member._id, 'contribution_changed', { from: oldAmount, to: newAmount })
|
logActivity(member._id, 'contribution_changed', { from: oldAmount, to: newAmount, cadence: body.cadence })
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldRequiresPayment = requiresPayment(oldAmount);
|
const oldRequiresPayment = requiresPayment(oldAmount);
|
||||||
|
|
@ -88,7 +88,7 @@ export default defineEventHandler(async (event) => {
|
||||||
dateActivated: new Date().toISOString().split("T")[0],
|
dateActivated: new Date().toISOString().split("T")[0],
|
||||||
paymentPlanId: parseInt(paymentPlanId),
|
paymentPlanId: parseInt(paymentPlanId),
|
||||||
customerCode,
|
customerCode,
|
||||||
recurringAmount: cadence === 'annual' ? newAmount * 12 : newAmount,
|
recurringAmount: newAmount,
|
||||||
paymentMethod: "card",
|
paymentMethod: "card",
|
||||||
},
|
},
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
|
|
@ -217,7 +217,7 @@ export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const subscriptionData = await updateHelcimSubscription(
|
const subscriptionData = await updateHelcimSubscription(
|
||||||
member.helcimSubscriptionId,
|
member.helcimSubscriptionId,
|
||||||
{ recurringAmount: memberCadence === 'annual' ? newAmount * 12 : newAmount }
|
{ recurringAmount: newAmount }
|
||||||
);
|
);
|
||||||
|
|
||||||
await Member.findByIdAndUpdate(
|
await Member.findByIdAndUpdate(
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ const ACTIVITY_TYPES = [
|
||||||
'email_sent',
|
'email_sent',
|
||||||
'community_connections_updated',
|
'community_connections_updated',
|
||||||
'board_updated',
|
'board_updated',
|
||||||
|
'board_post_created',
|
||||||
'connection_requested',
|
'connection_requested',
|
||||||
'connection_confirmed',
|
'connection_confirmed',
|
||||||
'tag_suggested',
|
'tag_suggested',
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ export const helcimCreatePlanSchema = z.object({
|
||||||
export const helcimCustomerSchema = z.object({
|
export const helcimCustomerSchema = z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
email: z.string().trim().toLowerCase().email(),
|
email: z.string().trim().toLowerCase().email(),
|
||||||
circle: z.enum(['community', 'founder', 'practitioner']).optional(),
|
circle: z.enum(['community', 'founder', 'practitioner']).optional().default('community'),
|
||||||
contributionAmount: z.number().int().min(0).optional(),
|
contributionAmount: z.number().int().min(0).optional(),
|
||||||
agreedToGuidelines: z.literal(true)
|
agreedToGuidelines: z.literal(true)
|
||||||
})
|
})
|
||||||
|
|
@ -388,6 +388,10 @@ export const adminTagCreateSchema = z.object({
|
||||||
pool: z.enum(['craft', 'cooperative'])
|
pool: z.enum(['craft', 'cooperative'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const tagSuggestionReviewSchema = z.object({
|
||||||
|
action: z.enum(['approve', 'reject'])
|
||||||
|
})
|
||||||
|
|
||||||
// --- Board post / channel schemas ---
|
// --- Board post / channel schemas ---
|
||||||
|
|
||||||
export const boardPostCreateSchema = z.object({
|
export const boardPostCreateSchema = z.object({
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ export class SlackService {
|
||||||
memberEmail: string,
|
memberEmail: string,
|
||||||
circle: string,
|
circle: string,
|
||||||
contributionAmount: number,
|
contributionAmount: number,
|
||||||
|
cadence: string = 'monthly',
|
||||||
invitationStatus: string = "manual_invitation_required",
|
invitationStatus: string = "manual_invitation_required",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -148,7 +149,7 @@ export class SlackService {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "mrkdwn",
|
type: "mrkdwn",
|
||||||
text: `*Contribution:*\n$${contributionAmount}/month`,
|
text: `*Contribution:*\n$${contributionAmount}/${cadence === 'annual' ? 'yr' : 'mo'}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
|
||||||
import { requireAuth, getOptionalMember } from '../../../server/utils/auth.js'
|
import { requireAuth, getOptionalMember, getSignupBridgeMember } from '../../../server/utils/auth.js'
|
||||||
import { validateBody as importedValidateBody } from '../../../server/utils/validateBody.js'
|
import { validateBody as importedValidateBody } from '../../../server/utils/validateBody.js'
|
||||||
import { loadPublicEvent } from '../../../server/utils/loadEvent.js'
|
import { loadPublicEvent } from '../../../server/utils/loadEvent.js'
|
||||||
import { loadPublicSeries } from '../../../server/utils/loadSeries.js'
|
import { loadPublicSeries } from '../../../server/utils/loadSeries.js'
|
||||||
|
|
@ -12,7 +12,8 @@ import { createMockEvent } from '../helpers/createMockEvent.js'
|
||||||
|
|
||||||
vi.mock('../../../server/utils/auth.js', () => ({
|
vi.mock('../../../server/utils/auth.js', () => ({
|
||||||
requireAuth: vi.fn(),
|
requireAuth: vi.fn(),
|
||||||
getOptionalMember: vi.fn()
|
getOptionalMember: vi.fn(),
|
||||||
|
getSignupBridgeMember: vi.fn()
|
||||||
}))
|
}))
|
||||||
vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() }))
|
vi.mock('../../../server/utils/validateBody.js', () => ({ validateBody: vi.fn() }))
|
||||||
vi.mock('../../../server/utils/schemas.js', () => ({ paymentVerifySchema: {} }))
|
vi.mock('../../../server/utils/schemas.js', () => ({ paymentVerifySchema: {} }))
|
||||||
|
|
@ -367,4 +368,27 @@ describe('verify-payment endpoint', () => {
|
||||||
statusMessage: 'Payment method not found or does not belong to this customer'
|
statusMessage: 'Payment method not found or does not belong to this customer'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('accepts the signup-bridge cookie without requiring auth', async () => {
|
||||||
|
const body = { customerId: 'cust-1', cardToken: 'tok-match' }
|
||||||
|
getSignupBridgeMember.mockResolvedValue({ _id: 'm1' })
|
||||||
|
importedValidateBody.mockResolvedValue(body)
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
text: async () => JSON.stringify([{ cardToken: 'tok-match' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = createMockEvent({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/helcim/verify-payment',
|
||||||
|
body
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await verifyPaymentHandler(event)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(getSignupBridgeMember).toHaveBeenCalledWith(event)
|
||||||
|
expect(requireAuth).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,7 @@ describe('helcim subscription endpoint', () => {
|
||||||
expect(result.subscription.nextBillingDate).toBe('2026-06-01')
|
expect(result.subscription.nextBillingDate).toBe('2026-06-01')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('annual $15 tier creates subscription with correct paymentPlanId and recurringAmount', async () => {
|
it('annual $180 tier creates subscription with correct paymentPlanId and recurringAmount', async () => {
|
||||||
requireAuth.mockResolvedValue(undefined)
|
requireAuth.mockResolvedValue(undefined)
|
||||||
requiresPayment.mockReturnValue(true)
|
requiresPayment.mockReturnValue(true)
|
||||||
getHelcimPlanId.mockReturnValue('88888')
|
getHelcimPlanId.mockReturnValue('88888')
|
||||||
|
|
@ -211,7 +211,7 @@ describe('helcim subscription endpoint', () => {
|
||||||
email: 'annual@example.com',
|
email: 'annual@example.com',
|
||||||
name: 'Annual User',
|
name: 'Annual User',
|
||||||
circle: 'founder',
|
circle: 'founder',
|
||||||
contributionAmount: 15,
|
contributionAmount: 180,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}
|
}
|
||||||
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-3', status: 'pending_payment' })
|
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-3', status: 'pending_payment' })
|
||||||
|
|
@ -223,7 +223,7 @@ describe('helcim subscription endpoint', () => {
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/api/helcim/subscription',
|
path: '/api/helcim/subscription',
|
||||||
body: { customerId: 'cust-1', contributionAmount: 15, customerCode: 'code-1', cardToken: 'tok-123', cadence: 'annual' }
|
body: { customerId: 'cust-1', contributionAmount: 180, customerCode: 'code-1', cardToken: 'tok-123', cadence: 'annual' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await subscriptionHandler(event)
|
const result = await subscriptionHandler(event)
|
||||||
|
|
@ -235,13 +235,13 @@ describe('helcim subscription endpoint', () => {
|
||||||
)
|
)
|
||||||
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
|
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
|
||||||
{ helcimCustomerId: 'cust-1' },
|
{ helcimCustomerId: 'cust-1' },
|
||||||
{ $set: expect.objectContaining({ billingCadence: 'annual', contributionAmount: 15, status: 'active' }) },
|
{ $set: expect.objectContaining({ billingCadence: 'annual', contributionAmount: 180, status: 'active' }) },
|
||||||
{ new: false, runValidators: false, projection: { status: 1 } }
|
{ new: false, runValidators: false, projection: { status: 1 } }
|
||||||
)
|
)
|
||||||
expect(Member.findById).toHaveBeenCalledWith('member-3')
|
expect(Member.findById).toHaveBeenCalledWith('member-3')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('annual $50 tier recurringAmount is 600', async () => {
|
it('annual $600 tier recurringAmount is 600', async () => {
|
||||||
requireAuth.mockResolvedValue(undefined)
|
requireAuth.mockResolvedValue(undefined)
|
||||||
requiresPayment.mockReturnValue(true)
|
requiresPayment.mockReturnValue(true)
|
||||||
getHelcimPlanId.mockReturnValue('88888')
|
getHelcimPlanId.mockReturnValue('88888')
|
||||||
|
|
@ -251,7 +251,7 @@ describe('helcim subscription endpoint', () => {
|
||||||
email: 'top@example.com',
|
email: 'top@example.com',
|
||||||
name: 'Top Tier',
|
name: 'Top Tier',
|
||||||
circle: 'practitioner',
|
circle: 'practitioner',
|
||||||
contributionAmount: 50,
|
contributionAmount: 600,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}
|
}
|
||||||
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-4', status: 'pending_payment' })
|
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-4', status: 'pending_payment' })
|
||||||
|
|
@ -263,7 +263,7 @@ describe('helcim subscription endpoint', () => {
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/api/helcim/subscription',
|
path: '/api/helcim/subscription',
|
||||||
body: { customerId: 'cust-2', contributionAmount: 50, customerCode: 'code-2', cardToken: 'tok-456', cadence: 'annual' }
|
body: { customerId: 'cust-2', contributionAmount: 600, customerCode: 'code-2', cardToken: 'tok-456', cadence: 'annual' }
|
||||||
})
|
})
|
||||||
|
|
||||||
await subscriptionHandler(event)
|
await subscriptionHandler(event)
|
||||||
|
|
|
||||||
|
|
@ -83,10 +83,10 @@ describe('update-contribution endpoint — Case 3 (paid→paid)', () => {
|
||||||
expect(result.message).toBe('Successfully updated contribution level')
|
expect(result.message).toBe('Successfully updated contribution level')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('annual $5 → $15: calls updateHelcimSubscription with recurringAmount 180', async () => {
|
it('annual $60 → $180: calls updateHelcimSubscription with recurringAmount 180', async () => {
|
||||||
const mockMember = {
|
const mockMember = {
|
||||||
_id: 'member-2',
|
_id: 'member-2',
|
||||||
contributionAmount: 5,
|
contributionAmount: 60,
|
||||||
helcimSubscriptionId: 'sub-2',
|
helcimSubscriptionId: 'sub-2',
|
||||||
billingCadence: 'annual',
|
billingCadence: 'annual',
|
||||||
}
|
}
|
||||||
|
|
@ -97,7 +97,7 @@ describe('update-contribution endpoint — Case 3 (paid→paid)', () => {
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/api/members/update-contribution',
|
path: '/api/members/update-contribution',
|
||||||
body: { contributionAmount: 15, cadence: 'annual' },
|
body: { contributionAmount: 180, cadence: 'annual' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await handler(event)
|
const result = await handler(event)
|
||||||
|
|
@ -105,16 +105,16 @@ describe('update-contribution endpoint — Case 3 (paid→paid)', () => {
|
||||||
expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-2', { recurringAmount: 180 })
|
expect(updateHelcimSubscription).toHaveBeenCalledWith('sub-2', { recurringAmount: 180 })
|
||||||
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
||||||
'member-2',
|
'member-2',
|
||||||
{ $set: { contributionAmount: 15 } },
|
{ $set: { contributionAmount: 180 } },
|
||||||
{ runValidators: false }
|
{ runValidators: false }
|
||||||
)
|
)
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('annual $15 → $50: calls updateHelcimSubscription with recurringAmount 600', async () => {
|
it('annual $180 → $600: calls updateHelcimSubscription with recurringAmount 600', async () => {
|
||||||
const mockMember = {
|
const mockMember = {
|
||||||
_id: 'member-3',
|
_id: 'member-3',
|
||||||
contributionAmount: 15,
|
contributionAmount: 180,
|
||||||
helcimSubscriptionId: 'sub-3',
|
helcimSubscriptionId: 'sub-3',
|
||||||
billingCadence: 'annual',
|
billingCadence: 'annual',
|
||||||
}
|
}
|
||||||
|
|
@ -125,7 +125,7 @@ describe('update-contribution endpoint — Case 3 (paid→paid)', () => {
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/api/members/update-contribution',
|
path: '/api/members/update-contribution',
|
||||||
body: { contributionAmount: 50, cadence: 'annual' },
|
body: { contributionAmount: 600, cadence: 'annual' },
|
||||||
})
|
})
|
||||||
|
|
||||||
await handler(event)
|
await handler(event)
|
||||||
|
|
@ -296,7 +296,7 @@ describe('update-contribution endpoint — Case 1 (free→paid)', () => {
|
||||||
const event = createMockEvent({
|
const event = createMockEvent({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/api/members/update-contribution',
|
path: '/api/members/update-contribution',
|
||||||
body: { contributionAmount: 15, cadence: 'annual' },
|
body: { contributionAmount: 180, cadence: 'annual' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await handler(event)
|
const result = await handler(event)
|
||||||
|
|
@ -307,7 +307,7 @@ describe('update-contribution endpoint — Case 1 (free→paid)', () => {
|
||||||
)
|
)
|
||||||
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
||||||
'member-c1',
|
'member-c1',
|
||||||
{ $set: expect.objectContaining({ billingCadence: 'annual', contributionAmount: 15, helcimSubscriptionId: 'sub-annual' }) },
|
{ $set: expect.objectContaining({ billingCadence: 'annual', contributionAmount: 180, helcimSubscriptionId: 'sub-annual' }) },
|
||||||
{ runValidators: false }
|
{ runValidators: false }
|
||||||
)
|
)
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue