Compare commits

..

21 commits

Author SHA1 Message Date
10a28ac5ef feat(helcim): accept signup-bridge cookie in verify-payment
All checks were successful
Test / vitest (push) Successful in 13m42s
Test / playwright (push) Successful in 19m35s
Test / Notify on failure (push) Has been skipped
Membership signup verifies the card before email verification, so the
signup-bridge cookie set by /api/helcim/customer now satisfies auth in
verify-payment when no session exists. Adds a cloudflared tunnel script
for testing the Helcim flow locally against a production build.
2026-05-24 14:01:02 +01:00
151481f1ec feat(admin): rename series route and add tags review page
Rename /admin/series-management to /admin/series so it follows the
/admin/<section> convention; the breadcrumb's auto-derived parent link
is now a real route (was a dead /admin/series link).

Add an /admin/tags page to review pending TagSuggestions — list,
approve (creates the Tag), reject — backed by new admin endpoints and a
tagSuggestionReviewSchema. Resolves the dead /admin/tags alert link.
2026-05-24 00:44:14 +01:00
7beb86b430 fix(activity): allow board_post_created in ActivityLog enum
The model enum array had drifted from the canonical type list in
utils/activityLog.js, so logging board-post activity failed validation.
2026-05-24 00:44:01 +01:00
039a6802e3 fix(e2e): repair failing suite — a11y fixes and stale assertions
Three product a11y defects: drop role="radiogroup" from the /join PWYC
<ul> (it stripped the list role; native radios already group), use
--parch-text on the active contribution chip (was --text-bright, 1.17:1
on --parch), and label the New tag pool USelect on event create.

Three stale tests: real event-type filter labels, updated location
placeholder, and click the label instead of the hidden 0×0 radio.
2026-05-24 00:43:54 +01:00
fee5959818 feat(join): redesign /join page with split hero and unified contribution list
Restructures /join.vue per docs/specs/join-form-redesign.md:

- Split hero (1fr 1fr) matching about.vue rhythm: H1 + tagline on left,
  "What you get" benefits on right (including "Pick your circle anytime
  after signup")
- Form section (1fr 1fr): form on left, "About the money" trust copy on
  right
- Inline PWYC editorial list with visually-hidden radio inputs for
  keyboard accessibility; cadence-aware preset amounts ($0/$5/$15/$30/$50
  monthly, ×12 annual); $15 row tagged "Suggested"
- Custom-amount row commits on blur and snaps to matching preset
- Cadence toggle (Monthly · Annual) in the section header; switching
  multiplies/floor-divides both form.contributionAmount and the custom
  amount display
- Removed: circle radio picker (defers to post-signup), ParchmentInset
  "How membership works", bottom three-circle row
- Submit row: bare agreement checkbox + auto-width button, wraps at 480px
- form.circle stays initialized to "community" and submits unchanged

Tests updated for new markup (radio ids #pwyc-N, .submit-btn class).
Cadence/billing-summary and contribution-guidance tests retired with
the ContributionAmountField usage on this page.
2026-05-23 18:57:11 +01:00
c85b2ae3d9 feat(api): default helcim customer circle to community when omitted
Lets the join form omit circle from the signup payload (circle picker
moves to a post-signup prompt per docs/specs/join-form-redesign.md).
Mongoose Member.circle is required with no default, so the Zod
default fills it in before reaching the model.
2026-05-23 18:49:55 +01:00
5d4321612f fix(forms): use expression form for conditional fieldError reset
Vue template attribute expressions don't allow if-statements; the
short-circuit form (`x && (x = '')`) is the idiomatic equivalent and
runs without a template compiler warning.
2026-05-23 18:49:22 +01:00
e5f1e9f95e fix(activity): cadence-aware suffixes on contribution log entries 2026-05-23 16:18:32 +01:00
10f8cab6e3 feat(forms): add inline blur validation for name and email 2026-05-23 16:09:36 +01:00
1079e8212f feat(forms): tighten labels and add Circle helper text
- join.vue: "Full Name" -> "Name", "Email Address" -> "Email", drop redundant "Your name" placeholder
- join.vue + accept-invite.vue: unify Circle helper copy to "Where you are in your co-op journey. You can change this anytime."
- join.vue: add .field-note style rule for the new helper paragraph
2026-05-23 16:05:15 +01:00
ad63a37a05 feat(contribution): port account.vue to ContributionAmountField
Replace the inline contribution UI (label, input row, presets, guidance)
with the shared ContributionAmountField component, locking cadence and
suppressing its built-in summary (account.vue has its own change hint).

Fix three computeds that double-applied the cadence conversion now that
Member.contributionAmount is stored in cadence-unit (post-Task 7):
contributionChangeHint, currentContributionLabel, and nextChargeAmount
no longer multiply annual amounts by 12.

Convert form.contributionAmount to a monthly-equivalent before the
payment-setup redirect — that page is monthly-only and would otherwise
attempt an annual-sized monthly charge for annual members.

Drop the now-unused guidanceLabel computed, the CONTRIBUTION_PRESETS and
getGuidanceLabel imports, and the dead contribution-* CSS rules.
2026-05-23 15:58:53 +01:00
aa6a176fb9 feat(migration): convert annual contributionAmount to cadence-unit
One-time script to convert existing annual Member records from the legacy
monthly-equivalent interpretation to cadence-unit. Multiplies
contributionAmount by 12 for billingCadence='annual' members.

Companion to commit 5023fb1 which dropped the server's ×12 on annual
recurringAmount. Must run after deploy, before annual members renew.

Idempotent via a transient `contributionAmountConverted: true` marker
field on each migrated doc — re-runs are safe. Dry-run by default;
`--apply` to write. Skips null/undefined contributionAmount, logs $0
amounts as no-ops.
2026-05-23 15:54:51 +01:00
f848773887 fix(admin): round monthlyRevenue and drop dead cadence ternaries 2026-05-23 15:52:26 +01:00
0dd68ff1aa fix(display): cadence-aware contribution suffix across UI + admin dashboard
Add formatContribution helper in app/config/contributions.js and route
all member-facing and admin contribution displays through cadence-aware
expressions so annual members see /yr instead of /mo. Normalize annual
amounts to monthly equivalents in the admin dashboard revenue
aggregate now that contributionAmount is stored in cadence units.
2026-05-23 15:47:10 +01:00
5023fb14ad fix(server): treat contributionAmount as cadence-unit (drop ×12)
ContributionAmountField now emits cadence-unit values (180 for $180/yr,
15 for $15/mo). Server endpoints were still multiplying annual by 12,
which would have charged $2160/yr instead of $180/yr after the form
ports in Tasks 2–3.

- helcim/subscription.post.js: recurringAmount = contributionAmount
  (no more × 12 for annual)
- members/update-contribution.post.js: same drop in both Case 1
  (free→paid) and Case 3 (paid→paid)
- slack.ts notifyNewMember: new positional `cadence` param so the
  Slack notification suffix renders /yr or /mo instead of hardcoded
  /month; all three call sites updated to pass member.billingCadence
- tests updated to match the new contract:
  - helcim-subscription.test.js: annual tests now send the cadence-
    unit amount (180, 600) and expect the same recurringAmount
  - update-contribution.test.js: annual Case 1 and Case 3 tests
    updated likewise
2026-05-23 15:37:52 +01:00
e0e7da5cca feat(contribution): port accept-invite.vue to ContributionAmountField
Replace cadence radios, contribution input, preset chips, guidance label,
and billing summary block with a single ContributionAmountField usage.
Default contribution updated to 180 to preserve the previous $15/mo
suggested annual default (cadence-unit value now). Updated flowSummary to
format cadence-unit directly. Updated e2e selectors to use the data-testids
the component exposes and new summary copy.
2026-05-23 15:14:33 +01:00
3126ddb8ea fix(join): format flowSummary contribution in cadence units 2026-05-23 15:10:48 +01:00
2f229cbfa0 test(join): align e2e with new ContributionAmountField
Add data-testid hooks for the contribution amount input and cadence
toggle labels so playwright can target them through useId-generated
ids. Update join-flow.spec.js to use the new selectors and to assert
the new billing-summary copy ('at each annual renewal' / 'each month'),
dropping the obsolete '/month x 12' parenthetical.
2026-05-23 15:08:03 +01:00
26ee1ca60d feat(contribution): port join.vue to ContributionAmountField
Replace inline cadence radios, contribution input + presets, guidance
label, and billing summary with the shared ContributionAmountField
component. Removes duplicated state (guidanceLabel, firstCharge),
unused imports (CONTRIBUTION_PRESETS, getGuidanceLabel), and the
matching CSS rules. The parent retains the cadence ref because
formatContributionAmount (left-column tier list) reads it.
2026-05-23 15:01:54 +01:00
f28558a433 fix(contribution): sanitize amount input and a11y polish 2026-05-23 14:58:39 +01:00
81783866d1 feat(contribution): extract ContributionAmountField component 2026-05-23 14:53:47 +01:00
35 changed files with 1801 additions and 1191 deletions

View 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>

View file

@ -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}`
}

View file

@ -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"

View file

@ -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 &times; 12)</span>.
</p>
<p class="billing-summary-line">
Then <strong>${{ firstCharge }} every {{ cadence === 'annual' ? 'year' : 'month' }}</strong>, until you cancel.
</p>
</div>
</div>
<div class="form-group full-width"> <div class="form-group full-width">
<label class="checkbox-label"> <label class="checkbox-label">
<input <input
@ -236,11 +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 {
}; name: form.name,
email: preRegEmail.value,
const flowSummary = computed(() => ({ circle: form.circle,
name: form.name, contribution: amount > 0 ? `$${amount}${suffix}` : "$0",
email: preRegEmail.value, };
circle: form.circle, });
contribution: formatContributionAmount(form.contributionAmount),
}));
const closeFlowOverlay = () => { const closeFlowOverlay = () => {
flowState.value = "idle"; flowState.value = "idle";
@ -525,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>

View file

@ -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' },

View file

@ -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.

View file

@ -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;

View file

@ -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">&larr; Series</NuxtLink> <NuxtLink to="/admin/series" class="back-link">&larr; 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)
} }

View 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>

File diff suppressed because it is too large Load diff

View file

@ -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;

View file

@ -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();

View file

@ -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'
icon: 'i-lucide-coins' return {
}), text: `Changed contribution from $${m.from}${suffix} to $${m.to}${suffix}`,
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'
icon: 'i-lucide-credit-card' return {
}), text: m.amount != null ? `Started $${m.amount}${suffix} subscription` : 'Started subscription',
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'

View file

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

View file

@ -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(

View file

@ -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
View 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')
})
})

View file

@ -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 })
}) })

View file

@ -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
View 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"

View 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)
})

View file

@ -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,

View 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 } }
})

View 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
}))
}
})

View file

@ -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'
) )
} }

View file

@ -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 {
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) 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

View file

@ -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'
) )
} }

View file

@ -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(

View file

@ -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',

View file

@ -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({

View file

@ -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'}`,
}, },
], ],
}, },

View file

@ -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()
})
}) })

View file

@ -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)

View file

@ -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)