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)
|
||||
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>
|
||||
<NuxtLink
|
||||
to="/admin/series-management"
|
||||
to="/admin/series"
|
||||
:class="{ active: route.path.includes('/admin/series') }"
|
||||
>
|
||||
Series
|
||||
|
|
@ -66,6 +66,14 @@
|
|||
Board Channels
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/admin/tags"
|
||||
:class="{ active: route.path.startsWith('/admin/tags') }"
|
||||
>
|
||||
Tags
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/admin/site-content"
|
||||
|
|
@ -153,7 +161,7 @@
|
|||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/admin/series-management"
|
||||
to="/admin/series"
|
||||
:class="{ active: route.path.includes('/admin/series') }"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>
|
||||
|
|
@ -178,6 +186,15 @@
|
|||
Board Channels
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/admin/tags"
|
||||
:class="{ active: route.path.startsWith('/admin/tags') }"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>
|
||||
Tags
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink
|
||||
to="/admin/site-content"
|
||||
|
|
|
|||
|
|
@ -32,7 +32,10 @@
|
|||
class="form-input"
|
||||
type="text"
|
||||
required
|
||||
@blur="validateName"
|
||||
@input="fieldErrors.name && (fieldErrors.name = '')"
|
||||
>
|
||||
<p v-if="fieldErrors.name" class="field-error">{{ fieldErrors.name }}</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="accept-email">Email</label>
|
||||
|
|
@ -68,7 +71,7 @@
|
|||
|
||||
<div class="form-group full-width">
|
||||
<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-radio community">
|
||||
<input
|
||||
|
|
@ -124,77 +127,13 @@
|
|||
</div>
|
||||
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Billing Cadence</label>
|
||||
<div class="cadence-radios">
|
||||
<div class="circle-radio">
|
||||
<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>
|
||||
<ContributionAmountField
|
||||
v-model="form.contributionAmount"
|
||||
v-model:cadence="cadence"
|
||||
/>
|
||||
<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 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">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
|
|
@ -236,11 +175,7 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
requiresPayment,
|
||||
CONTRIBUTION_PRESETS,
|
||||
getGuidanceLabel,
|
||||
} from "~/config/contributions";
|
||||
import { requiresPayment } from "~/config/contributions";
|
||||
|
||||
definePageMeta({ layout: false });
|
||||
useSiteMeta({ title: "Accept Invitation", noindex: true });
|
||||
|
|
@ -265,10 +200,17 @@ const form = reactive({
|
|||
location: "",
|
||||
circle: "community",
|
||||
motivation: "",
|
||||
contributionAmount: 15,
|
||||
contributionAmount: 180,
|
||||
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(() => {
|
||||
return (
|
||||
form.name &&
|
||||
|
|
@ -283,26 +225,16 @@ const needsPayment = computed(() => {
|
|||
return requiresPayment(form.contributionAmount);
|
||||
});
|
||||
|
||||
const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount));
|
||||
|
||||
const firstCharge = computed(() => {
|
||||
const flowSummary = computed(() => {
|
||||
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";
|
||||
return `$${display}${suffix}`;
|
||||
};
|
||||
|
||||
const flowSummary = computed(() => ({
|
||||
name: form.name,
|
||||
email: preRegEmail.value,
|
||||
circle: form.circle,
|
||||
contribution: formatContributionAmount(form.contributionAmount),
|
||||
}));
|
||||
return {
|
||||
name: form.name,
|
||||
email: preRegEmail.value,
|
||||
circle: form.circle,
|
||||
contribution: amount > 0 ? `$${amount}${suffix}` : "$0",
|
||||
};
|
||||
});
|
||||
|
||||
const closeFlowOverlay = () => {
|
||||
flowState.value = "idle";
|
||||
|
|
@ -525,71 +457,11 @@ textarea.form-input {
|
|||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ---- CONTRIBUTION AMOUNT INPUT + CHIPS ---- */
|
||||
.contribution-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.contribution-currency {
|
||||
font-weight: 600;
|
||||
}
|
||||
.contribution-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--parch);
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.contribution-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--candle);
|
||||
}
|
||||
.contribution-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.contribution-preset-chip {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--parch);
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.contribution-preset-chip:hover {
|
||||
border-style: solid;
|
||||
border-color: var(--candle);
|
||||
}
|
||||
.contribution-guidance {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
color: var(--ink-soft, currentColor);
|
||||
}
|
||||
|
||||
/* ---- BILLING SUMMARY ---- */
|
||||
.billing-summary {
|
||||
padding: 12px 16px;
|
||||
border: 1px dashed var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.billing-summary-line {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
.billing-summary-line + .billing-summary-line {
|
||||
.field-error {
|
||||
font-size: 11px;
|
||||
color: var(--ember);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.billing-summary-line strong {
|
||||
color: var(--text-bright);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- CIRCLE RADIOS ---- */
|
||||
.circle-radios {
|
||||
|
|
@ -598,12 +470,6 @@ textarea.form-input {
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
.cadence-radios {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.circle-radio {
|
||||
position: relative;
|
||||
}
|
||||
|
|
@ -721,9 +587,5 @@ textarea.form-input {
|
|||
.circle-radios {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cadence-radios {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -350,6 +350,7 @@
|
|||
<label>New tag pool</label>
|
||||
<USelect
|
||||
v-model="newTagPool"
|
||||
aria-label="New tag pool"
|
||||
:items="[
|
||||
{ label: 'Cooperative', value: 'cooperative' },
|
||||
{ label: 'Craft', value: 'craft' },
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@
|
|||
</select>
|
||||
</div>
|
||||
<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">
|
||||
<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.
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@
|
|||
<td>
|
||||
<CircleBadge :circle="member.circle" />
|
||||
</td>
|
||||
<td class="col-mono">${{ member.contributionAmount ?? 0 }}/mo</td>
|
||||
<td class="col-mono">${{ member.contributionAmount ?? 0 }}{{ member.billingCadence === 'annual' ? '/yr' : '/mo' }}</td>
|
||||
<td>
|
||||
<span class="badge status" :class="`status-${member.status || 'pending_payment'}`">{{ statusLabel(member.status) }}</span>
|
||||
</td>
|
||||
|
|
@ -366,7 +366,7 @@
|
|||
</select>
|
||||
</div>
|
||||
<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">
|
||||
</div>
|
||||
<div class="field">
|
||||
|
|
@ -860,6 +860,7 @@ const editingMember = reactive({
|
|||
email: "",
|
||||
circle: "community",
|
||||
contributionAmount: 0,
|
||||
billingCadence: "monthly",
|
||||
status: "pending_payment",
|
||||
});
|
||||
|
||||
|
|
@ -870,6 +871,7 @@ const editMember = (member) => {
|
|||
email: member.email,
|
||||
circle: member.circle,
|
||||
contributionAmount: member.contributionAmount ?? 0,
|
||||
billingCadence: member.billingCadence || "monthly",
|
||||
status: member.status || "pending_payment",
|
||||
});
|
||||
showEditModal.value = true;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div class="create-form">
|
||||
<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>
|
||||
<p>Create a new event series to group related events together</p>
|
||||
</div>
|
||||
|
|
@ -98,7 +98,7 @@
|
|||
<!-- Form Actions -->
|
||||
<div class="form-actions">
|
||||
<NuxtLink
|
||||
to="/admin/series-management"
|
||||
to="/admin/series"
|
||||
class="btn"
|
||||
>
|
||||
Cancel
|
||||
|
|
@ -211,7 +211,7 @@ const createSeries = async (redirectAfter = true) => {
|
|||
|
||||
if (redirectAfter) {
|
||||
setTimeout(() => {
|
||||
router.push('/admin/series-management')
|
||||
router.push('/admin/series')
|
||||
}, 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>
|
||||
1551
app/pages/join.vue
1551
app/pages/join.vue
File diff suppressed because it is too large
Load diff
|
|
@ -173,9 +173,12 @@
|
|||
type="email"
|
||||
placeholder="you@example.com"
|
||||
autofocus
|
||||
@blur="validateNewEmail"
|
||||
@input="fieldErrors.email && (fieldErrors.email = '')"
|
||||
@keydown.enter="handleUpdateEmail"
|
||||
@keydown.escape="cancelEmailEdit"
|
||||
>
|
||||
<p v-if="fieldErrors.email" class="field-error">{{ fieldErrors.email }}</p>
|
||||
</div>
|
||||
<div class="email-edit-actions">
|
||||
<button
|
||||
|
|
@ -245,35 +248,13 @@
|
|||
<PageSection>
|
||||
<div class="section-label">Change Contribution</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="account-contribution">
|
||||
Monthly Contribution
|
||||
</label>
|
||||
<div class="contribution-input-row">
|
||||
<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>
|
||||
<ContributionAmountField
|
||||
v-model="form.contributionAmount"
|
||||
:cadence="cadence"
|
||||
:allow-cadence-change="false"
|
||||
:show-summary="false"
|
||||
/>
|
||||
|
||||
<div v-if="contributionChangeHint" class="tier-hint">
|
||||
{{ contributionChangeHint }}
|
||||
</div>
|
||||
|
|
@ -314,7 +295,7 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { CONTRIBUTION_PRESETS, getGuidanceLabel, requiresPayment } from '~/config/contributions';
|
||||
import { requiresPayment } from '~/config/contributions';
|
||||
import { STATUS_LABELS } from '~/config/memberStatus';
|
||||
|
||||
useSiteMeta({ title: 'Account', noindex: true });
|
||||
|
|
@ -339,6 +320,19 @@ const showEmailEdit = ref(false);
|
|||
const newEmail = ref("");
|
||||
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
|
||||
const paymentHistory = ref([]);
|
||||
const paymentHistoryLoading = ref(false);
|
||||
|
|
@ -373,14 +367,12 @@ const canChangeCard = computed(() => {
|
|||
|
||||
const cadence = computed(() => memberData.value?.billingCadence || 'monthly');
|
||||
|
||||
const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount));
|
||||
|
||||
const contributionChangeHint = computed(() => {
|
||||
const current = Number(memberData.value?.contributionAmount || 0);
|
||||
const next = Number(form.contributionAmount || 0);
|
||||
if (current === next) return "";
|
||||
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.`;
|
||||
}
|
||||
if (current > 0 && next === 0) {
|
||||
|
|
@ -392,14 +384,13 @@ const contributionChangeHint = computed(() => {
|
|||
const currentContributionLabel = computed(() => {
|
||||
const amount = Number(memberData.value?.contributionAmount || 0);
|
||||
if (!amount) return '$0';
|
||||
const displayAmount = cadence.value === 'annual' ? amount * 12 : amount;
|
||||
return cadence.value === 'annual' ? `$${displayAmount} / year` : `$${displayAmount} / month`;
|
||||
return cadence.value === 'annual' ? `$${amount} / year` : `$${amount} / month`;
|
||||
});
|
||||
|
||||
const nextChargeAmount = computed(() => {
|
||||
const amount = Number(memberData.value?.contributionAmount || 0);
|
||||
if (!amount) return null;
|
||||
return cadence.value === 'annual' ? amount * 12 : amount;
|
||||
return amount;
|
||||
});
|
||||
|
||||
const circleOptions = [
|
||||
|
|
@ -493,8 +484,14 @@ const handleUpdateContribution = async () => {
|
|||
} catch (err) {
|
||||
// Paid upgrade without a saved card — route to payment setup instead of erroring.
|
||||
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(
|
||||
`/member/payment-setup?tier=${form.contributionAmount}&circle=${
|
||||
`/member/payment-setup?tier=${monthlyTier}&circle=${
|
||||
selectedCircle.value || memberData.value?.circle || 'community'
|
||||
}`,
|
||||
);
|
||||
|
|
@ -535,6 +532,7 @@ const handleUpdateCircle = async () => {
|
|||
const cancelEmailEdit = () => {
|
||||
showEmailEdit.value = false;
|
||||
newEmail.value = "";
|
||||
fieldErrors.email = "";
|
||||
};
|
||||
|
||||
const handleUpdateEmail = async () => {
|
||||
|
|
@ -842,6 +840,11 @@ const confirmCancelMembership = async () => {
|
|||
.email-edit .field input:focus {
|
||||
border-color: var(--candle);
|
||||
}
|
||||
.email-edit .field .field-error {
|
||||
font-size: 11px;
|
||||
color: var(--ember);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.email-edit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
|
@ -890,52 +893,6 @@ const confirmCancelMembership = async () => {
|
|||
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 {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
<PageHeader :title="welcomeTitle">
|
||||
<div class="dashboard-meta">
|
||||
<CircleBadge :circle="memberData?.circle || 'community'" />
|
||||
<span>${{ memberData?.contributionAmount ?? 0 }} CAD/mo</span>
|
||||
<span>{{ formatContribution(memberData?.contributionAmount ?? 0, memberData?.billingCadence) }} CAD</span>
|
||||
</div>
|
||||
<p v-if="showSlackComingNote" class="slack-coming-note">
|
||||
Slack workspace access is part of your membership. Invitations are
|
||||
|
|
@ -171,7 +171,7 @@
|
|||
<div class="membership-row">
|
||||
<span class="key">Contribution</span>
|
||||
<span class="val"
|
||||
>${{ memberData?.contributionAmount ?? 0 }} CAD/month</span
|
||||
>{{ formatContribution(memberData?.contributionAmount ?? 0, memberData?.billingCadence) }} CAD</span
|
||||
>
|
||||
</div>
|
||||
<div class="membership-row">
|
||||
|
|
@ -220,6 +220,8 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { formatContribution } from '~/config/contributions';
|
||||
|
||||
useSiteMeta({ title: 'Dashboard', noindex: true });
|
||||
|
||||
const { memberData, checkMemberStatus } = useAuth();
|
||||
|
|
|
|||
|
|
@ -27,10 +27,13 @@ const formatters = {
|
|||
text: `Changed circle from ${circleLabel(m.from)} to ${circleLabel(m.to)}`,
|
||||
icon: 'i-lucide-refresh-cw'
|
||||
}),
|
||||
contribution_changed: (m) => ({
|
||||
text: `Changed contribution from $${m.from}/mo to $${m.to}/mo`,
|
||||
icon: 'i-lucide-coins'
|
||||
}),
|
||||
contribution_changed: (m) => {
|
||||
const suffix = m.cadence === 'annual' ? '/yr' : '/mo'
|
||||
return {
|
||||
text: `Changed contribution from $${m.from}${suffix} to $${m.to}${suffix}`,
|
||||
icon: 'i-lucide-coins'
|
||||
}
|
||||
},
|
||||
email_changed: (m) => ({
|
||||
text: `Changed email address`,
|
||||
icon: 'i-lucide-mail'
|
||||
|
|
@ -41,10 +44,13 @@ const formatters = {
|
|||
: 'Updated profile',
|
||||
icon: 'i-lucide-user-pen'
|
||||
}),
|
||||
subscription_created: (m) => ({
|
||||
text: m.amount != null ? `Started $${m.amount}/mo subscription` : 'Started subscription',
|
||||
icon: 'i-lucide-credit-card'
|
||||
}),
|
||||
subscription_created: (m) => {
|
||||
const suffix = m.cadence === 'annual' ? '/yr' : '/mo'
|
||||
return {
|
||||
text: m.amount != null ? `Started $${m.amount}${suffix} subscription` : 'Started subscription',
|
||||
icon: 'i-lucide-credit-card'
|
||||
}
|
||||
},
|
||||
subscription_cancelled: () => ({
|
||||
text: 'Cancelled subscription',
|
||||
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-founder')).toBeAttached()
|
||||
await expect(page.locator('#circle-practitioner')).toBeAttached()
|
||||
await expect(page.locator('#accept-cadence-monthly')).toBeAttached()
|
||||
await expect(page.locator('#accept-cadence-annual')).toBeAttached()
|
||||
await expect(page.locator('#accept-contribution')).toBeVisible()
|
||||
await expect(page.getByTestId('cadence-monthly')).toBeVisible()
|
||||
await expect(page.getByTestId('cadence-annual')).toBeVisible()
|
||||
await expect(page.getByTestId('contribution-amount')).toBeVisible()
|
||||
await expect(page.locator('.contribution-preset-chip').first()).toBeVisible()
|
||||
await expect(page.locator('.form-submit')).toBeVisible()
|
||||
})
|
||||
|
|
@ -110,15 +110,17 @@ test.describe('Accept Invite — pre-registrant signup', () => {
|
|||
await mockVerifyOk(page)
|
||||
await gotoAcceptInvite(page)
|
||||
|
||||
await expect(page.locator('#accept-contribution')).toBeVisible()
|
||||
await page.locator('#accept-contribution').fill('10')
|
||||
await expect(page.getByTestId('contribution-amount')).toBeVisible()
|
||||
await page.getByTestId('cadence-monthly').click()
|
||||
await page.getByTestId('contribution-amount').fill('10')
|
||||
|
||||
await page.locator('label[for="accept-cadence-monthly"]').click()
|
||||
await expect(page.locator('.billing-summary')).toContainText('$10 today')
|
||||
const summary = page.locator('.billing-summary')
|
||||
await expect(summary).toContainText('$10 today')
|
||||
await expect(summary).toContainText('each month')
|
||||
|
||||
await page.locator('label[for="accept-cadence-annual"]').click()
|
||||
await expect(page.locator('.billing-summary')).toContainText('$120 today')
|
||||
await expect(page.locator('.billing-summary')).toContainText('$10/month')
|
||||
await page.getByTestId('cadence-annual').click()
|
||||
await expect(summary).toContainText('$120 today')
|
||||
await expect(summary).toContainText('at each annual renewal')
|
||||
})
|
||||
|
||||
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, '')
|
||||
|
||||
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 }) => {
|
||||
|
|
@ -141,7 +143,7 @@ test.describe('Accept Invite — pre-registrant signup', () => {
|
|||
|
||||
await expect(page.locator('#accept-name')).toHaveValue('Free Tester')
|
||||
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 expect(page.locator('.form-submit')).toBeEnabled()
|
||||
|
|
@ -158,7 +160,7 @@ test.describe('Accept Invite — pre-registrant signup', () => {
|
|||
await mockVerifyOk(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 expect(page.locator('.form-submit')).toContainText(/Continue to Payment/)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ test.describe('Admin events CRUD', () => {
|
|||
.fill('e2e test event description')
|
||||
|
||||
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')
|
||||
|
||||
const startInput = adminPage.getByPlaceholder(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { test, expect } from './helpers/fixtures.js'
|
|||
|
||||
test.describe('Admin series management page', () => {
|
||||
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({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
|
@ -12,9 +12,9 @@ test.describe('Admin series management page', () => {
|
|||
|
||||
test.describe('Admin series access control', () => {
|
||||
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'))
|
||||
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.waitForURL('**/admin/series-management', { timeout: 15000 })
|
||||
await adminPage.waitForURL('**/admin/series', { timeout: 15000 })
|
||||
|
||||
const card = adminPage.locator('.series-card', { hasText: title })
|
||||
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')
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
|
@ -36,7 +36,7 @@ test.describe('Events list page', () => {
|
|||
// Wait for Vue hydration — the "All" filter should have the active class once reactive
|
||||
const allBtn = page.locator('.filter-btn', { hasText: 'All' })
|
||||
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 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)
|
||||
function mockHelcimAPIs(page, { failCustomer = false } = {}) {
|
||||
// Mock Helcim customer creation
|
||||
page.route('**/api/helcim/customer', async (route) => {
|
||||
if (failCustomer) {
|
||||
return route.fulfill({
|
||||
|
|
@ -26,7 +25,6 @@ function mockHelcimAPIs(page, { failCustomer = false } = {}) {
|
|||
})
|
||||
})
|
||||
|
||||
// Mock subscription creation
|
||||
page.route('**/api/helcim/subscription', async (route) => {
|
||||
return route.fulfill({
|
||||
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-email')).toBeVisible()
|
||||
await expect(page.locator('#circle-community')).toBeAttached()
|
||||
await expect(page.locator('#circle-founder')).toBeAttached()
|
||||
await expect(page.locator('#circle-practitioner')).toBeAttached()
|
||||
await expect(page.locator('#join-contribution')).toBeVisible()
|
||||
await expect(page.locator('.form-submit')).toBeVisible()
|
||||
await expect(page.locator('#pwyc-0')).toBeAttached()
|
||||
await expect(page.locator('#pwyc-15')).toBeAttached()
|
||||
await expect(page.locator('#pwyc-50')).toBeAttached()
|
||||
await expect(page.getByTestId('cadence-monthly')).toBeVisible()
|
||||
await expect(page.getByTestId('cadence-annual')).toBeVisible()
|
||||
await expect(page.locator('.submit-btn')).toBeVisible()
|
||||
})
|
||||
|
||||
test('submit button disabled when form incomplete', async ({ page }) => {
|
||||
await page.goto('/join')
|
||||
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-email').fill('')
|
||||
|
||||
// Button should be disabled with empty required fields
|
||||
await expect(page.locator('.form-submit')).toBeDisabled()
|
||||
await expect(page.locator('.submit-btn')).toBeDisabled()
|
||||
|
||||
// Fill only name — still incomplete
|
||||
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
|
||||
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
|
||||
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 }) => {
|
||||
|
|
@ -83,20 +81,17 @@ test.describe('Join page — member signup flow', () => {
|
|||
await page.goto('/join')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Fill in the form
|
||||
await page.locator('#join-name').fill('E2E Test User')
|
||||
await page.locator('#join-email').fill(uniqueEmail)
|
||||
await page.locator('#circle-community').check({ force: true })
|
||||
// Contribution is now a numeric input with preset chips, not a select
|
||||
await page.locator('#join-contribution').fill('0')
|
||||
// Pick the $0 preset
|
||||
await page.locator('label[for="pwyc-0"]').click()
|
||||
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 page.locator('.form-submit').click()
|
||||
await page.locator('.submit-btn').click()
|
||||
|
||||
// Free tier flips the SignupFlowOverlay into its success state
|
||||
await expect(
|
||||
|
|
@ -104,42 +99,26 @@ test.describe('Join page — member signup flow', () => {
|
|||
).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.waitForLoadState('networkidle')
|
||||
|
||||
await page.locator('#join-contribution').fill('10')
|
||||
await page.locator('label[for="cadence-annual"]').click()
|
||||
// Default is Monthly: the $15 row reads "$15"
|
||||
const suggestedRow = page.locator('#pwyc-15 + .pwyc-row-content .pwyc-amt')
|
||||
await expect(suggestedRow).toHaveText('$15')
|
||||
|
||||
const summary = page.locator('.billing-summary')
|
||||
await expect(summary).toBeVisible()
|
||||
await expect(summary).toContainText('$120 today')
|
||||
await expect(summary).toContainText('$10/month × 12')
|
||||
await expect(summary).toContainText('$120 every year')
|
||||
// Switch to Annual: same row now reads "$180"
|
||||
await page.getByTestId('cadence-annual').click()
|
||||
await expect(suggestedRow).toHaveText('$180')
|
||||
|
||||
await page.locator('label[for="cadence-monthly"]').click()
|
||||
await expect(summary).toContainText('$10 today')
|
||||
await expect(summary).toContainText('$10 every month')
|
||||
})
|
||||
|
||||
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/)
|
||||
// Switch back to Monthly: returns to "$15"
|
||||
await page.getByTestId('cadence-monthly').click()
|
||||
await expect(suggestedRow).toHaveText('$15')
|
||||
})
|
||||
|
||||
test('paid tier flow reaches success state with HelcimPay stubbed', async ({ page }) => {
|
||||
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(() => {
|
||||
window.appendHelcimPayIframe = (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-email').fill(uniqueEmail)
|
||||
await page.locator('#circle-community').check({ force: true })
|
||||
await page.locator('#join-contribution').fill('15')
|
||||
// $15 preset is selected by default — verify and submit
|
||||
await page.locator('#pwyc-15').check({ force: true })
|
||||
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
|
||||
|
||||
await expect(page.locator('.form-submit')).toBeEnabled()
|
||||
await page.locator('.form-submit').click()
|
||||
await expect(page.locator('.submit-btn')).toBeEnabled()
|
||||
await page.locator('.submit-btn').click()
|
||||
|
||||
await expect(
|
||||
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.waitForLoadState('networkidle')
|
||||
|
||||
// Mock customer endpoint to return 409 (email already exists)
|
||||
await mockHelcimAPIs(page, { failCustomer: true })
|
||||
|
||||
await page.locator('#join-name').fill('Dup Test User')
|
||||
await page.locator('#join-email').fill(duplicateEmail)
|
||||
await page.locator('#circle-community').check({ force: true })
|
||||
await page.locator('#join-contribution').fill('0')
|
||||
await page.locator('label[for="pwyc-0"]').click()
|
||||
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
|
||||
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 }
|
||||
})
|
||||
|
||||
// Calculate monthly revenue from member contributions
|
||||
const members = await Member.find({}, 'contributionAmount').lean()
|
||||
// Calculate monthly revenue from member contributions.
|
||||
// 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) => {
|
||||
return total + (member.contributionAmount || 0)
|
||||
const amt = member.contributionAmount || 0
|
||||
const monthlyEquivalent = member.billingCadence === 'annual' ? amt / 12 : amt
|
||||
return total + monthlyEquivalent
|
||||
}, 0)
|
||||
|
||||
const pendingSlackInvites = await Member.countDocuments({ slackInvited: false })
|
||||
|
|
@ -42,7 +46,7 @@ export default defineEventHandler(async (event) => {
|
|||
stats: {
|
||||
totalMembers,
|
||||
activeEvents,
|
||||
monthlyRevenue,
|
||||
monthlyRevenue: Math.round(monthlyRevenue),
|
||||
pendingSlackInvites
|
||||
},
|
||||
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 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)
|
||||
try {
|
||||
|
|
@ -47,6 +47,7 @@ export default defineEventHandler(async (event) => {
|
|||
member.email,
|
||||
member.circle,
|
||||
member.contributionAmount,
|
||||
member.billingCadence,
|
||||
'manual_invitation_required'
|
||||
)
|
||||
}
|
||||
|
|
@ -79,9 +80,7 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
const cadence = body.cadence
|
||||
const paymentPlanId = getHelcimPlanId(cadence)
|
||||
const recurringAmount = cadence === 'annual'
|
||||
? body.contributionAmount * 12
|
||||
: body.contributionAmount
|
||||
const recurringAmount = body.contributionAmount
|
||||
|
||||
if (!paymentPlanId) {
|
||||
throw createError({
|
||||
|
|
@ -135,7 +134,7 @@ export default defineEventHandler(async (event) => {
|
|||
const isFirstActivation = preMember?.status === 'pending_payment'
|
||||
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 {
|
||||
const txs = await listHelcimCustomerTransactions(body.customerCode)
|
||||
|
|
@ -159,6 +158,7 @@ export default defineEventHandler(async (event) => {
|
|||
member.email,
|
||||
member.circle,
|
||||
member.contributionAmount,
|
||||
member.billingCadence,
|
||||
'manual_invitation_required'
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
// 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 { paymentVerifySchema } from '../../utils/schemas.js'
|
||||
import { listHelcimCustomerCards } from '../../utils/helcim.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
await requireAuth(event)
|
||||
// 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)
|
||||
}
|
||||
const body = await validateBody(event, paymentVerifySchema)
|
||||
|
||||
// Verify the card token by fetching the customer's cards from Helcim
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export default defineEventHandler(async (event) => {
|
|||
member.email,
|
||||
member.circle,
|
||||
member.contributionAmount,
|
||||
member.billingCadence,
|
||||
'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)
|
||||
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);
|
||||
|
|
@ -88,7 +88,7 @@ export default defineEventHandler(async (event) => {
|
|||
dateActivated: new Date().toISOString().split("T")[0],
|
||||
paymentPlanId: parseInt(paymentPlanId),
|
||||
customerCode,
|
||||
recurringAmount: cadence === 'annual' ? newAmount * 12 : newAmount,
|
||||
recurringAmount: newAmount,
|
||||
paymentMethod: "card",
|
||||
},
|
||||
idempotencyKey,
|
||||
|
|
@ -217,7 +217,7 @@ export default defineEventHandler(async (event) => {
|
|||
try {
|
||||
const subscriptionData = await updateHelcimSubscription(
|
||||
member.helcimSubscriptionId,
|
||||
{ recurringAmount: memberCadence === 'annual' ? newAmount * 12 : newAmount }
|
||||
{ recurringAmount: newAmount }
|
||||
);
|
||||
|
||||
await Member.findByIdAndUpdate(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const ACTIVITY_TYPES = [
|
|||
'email_sent',
|
||||
'community_connections_updated',
|
||||
'board_updated',
|
||||
'board_post_created',
|
||||
'connection_requested',
|
||||
'connection_confirmed',
|
||||
'tag_suggested',
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export const helcimCreatePlanSchema = z.object({
|
|||
export const helcimCustomerSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
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(),
|
||||
agreedToGuidelines: z.literal(true)
|
||||
})
|
||||
|
|
@ -388,6 +388,10 @@ export const adminTagCreateSchema = z.object({
|
|||
pool: z.enum(['craft', 'cooperative'])
|
||||
})
|
||||
|
||||
export const tagSuggestionReviewSchema = z.object({
|
||||
action: z.enum(['approve', 'reject'])
|
||||
})
|
||||
|
||||
// --- Board post / channel schemas ---
|
||||
|
||||
export const boardPostCreateSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ export class SlackService {
|
|||
memberEmail: string,
|
||||
circle: string,
|
||||
contributionAmount: number,
|
||||
cadence: string = 'monthly',
|
||||
invitationStatus: string = "manual_invitation_required",
|
||||
): Promise<void> {
|
||||
try {
|
||||
|
|
@ -148,7 +149,7 @@ export class SlackService {
|
|||
},
|
||||
{
|
||||
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 { 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 { loadPublicEvent } from '../../../server/utils/loadEvent.js'
|
||||
import { loadPublicSeries } from '../../../server/utils/loadSeries.js'
|
||||
|
|
@ -12,7 +12,8 @@ import { createMockEvent } from '../helpers/createMockEvent.js'
|
|||
|
||||
vi.mock('../../../server/utils/auth.js', () => ({
|
||||
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/schemas.js', () => ({ paymentVerifySchema: {} }))
|
||||
|
|
@ -367,4 +368,27 @@ describe('verify-payment endpoint', () => {
|
|||
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')
|
||||
})
|
||||
|
||||
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)
|
||||
requiresPayment.mockReturnValue(true)
|
||||
getHelcimPlanId.mockReturnValue('88888')
|
||||
|
|
@ -211,7 +211,7 @@ describe('helcim subscription endpoint', () => {
|
|||
email: 'annual@example.com',
|
||||
name: 'Annual User',
|
||||
circle: 'founder',
|
||||
contributionAmount: 15,
|
||||
contributionAmount: 180,
|
||||
status: 'active',
|
||||
}
|
||||
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-3', status: 'pending_payment' })
|
||||
|
|
@ -223,7 +223,7 @@ describe('helcim subscription endpoint', () => {
|
|||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
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)
|
||||
|
|
@ -235,13 +235,13 @@ describe('helcim subscription endpoint', () => {
|
|||
)
|
||||
expect(Member.findOneAndUpdate).toHaveBeenCalledWith(
|
||||
{ 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 } }
|
||||
)
|
||||
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)
|
||||
requiresPayment.mockReturnValue(true)
|
||||
getHelcimPlanId.mockReturnValue('88888')
|
||||
|
|
@ -251,7 +251,7 @@ describe('helcim subscription endpoint', () => {
|
|||
email: 'top@example.com',
|
||||
name: 'Top Tier',
|
||||
circle: 'practitioner',
|
||||
contributionAmount: 50,
|
||||
contributionAmount: 600,
|
||||
status: 'active',
|
||||
}
|
||||
Member.findOneAndUpdate.mockResolvedValue({ _id: 'member-4', status: 'pending_payment' })
|
||||
|
|
@ -263,7 +263,7 @@ describe('helcim subscription endpoint', () => {
|
|||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -83,10 +83,10 @@ describe('update-contribution endpoint — Case 3 (paid→paid)', () => {
|
|||
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 = {
|
||||
_id: 'member-2',
|
||||
contributionAmount: 5,
|
||||
contributionAmount: 60,
|
||||
helcimSubscriptionId: 'sub-2',
|
||||
billingCadence: 'annual',
|
||||
}
|
||||
|
|
@ -97,7 +97,7 @@ describe('update-contribution endpoint — Case 3 (paid→paid)', () => {
|
|||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/members/update-contribution',
|
||||
body: { contributionAmount: 15, cadence: 'annual' },
|
||||
body: { contributionAmount: 180, cadence: 'annual' },
|
||||
})
|
||||
|
||||
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(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
||||
'member-2',
|
||||
{ $set: { contributionAmount: 15 } },
|
||||
{ $set: { contributionAmount: 180 } },
|
||||
{ runValidators: false }
|
||||
)
|
||||
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 = {
|
||||
_id: 'member-3',
|
||||
contributionAmount: 15,
|
||||
contributionAmount: 180,
|
||||
helcimSubscriptionId: 'sub-3',
|
||||
billingCadence: 'annual',
|
||||
}
|
||||
|
|
@ -125,7 +125,7 @@ describe('update-contribution endpoint — Case 3 (paid→paid)', () => {
|
|||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/members/update-contribution',
|
||||
body: { contributionAmount: 50, cadence: 'annual' },
|
||||
body: { contributionAmount: 600, cadence: 'annual' },
|
||||
})
|
||||
|
||||
await handler(event)
|
||||
|
|
@ -296,7 +296,7 @@ describe('update-contribution endpoint — Case 1 (free→paid)', () => {
|
|||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/members/update-contribution',
|
||||
body: { contributionAmount: 15, cadence: 'annual' },
|
||||
body: { contributionAmount: 180, cadence: 'annual' },
|
||||
})
|
||||
|
||||
const result = await handler(event)
|
||||
|
|
@ -307,7 +307,7 @@ describe('update-contribution endpoint — Case 1 (free→paid)', () => {
|
|||
)
|
||||
expect(Member.findByIdAndUpdate).toHaveBeenCalledWith(
|
||||
'member-c1',
|
||||
{ $set: expect.objectContaining({ billingCadence: 'annual', contributionAmount: 15, helcimSubscriptionId: 'sub-annual' }) },
|
||||
{ $set: expect.objectContaining({ billingCadence: 'annual', contributionAmount: 180, helcimSubscriptionId: 'sub-annual' }) },
|
||||
{ runValidators: false }
|
||||
)
|
||||
expect(result.success).toBe(true)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue