Copy and layout improvements.

This commit is contained in:
Jennie Robinson Faber 2026-04-16 21:11:05 +01:00
parent 39eb9e039a
commit 02222a5c16
20 changed files with 464 additions and 652 deletions

View file

@ -273,6 +273,90 @@ p a, blockquote a {
min-width: 0; min-width: 0;
} }
/* ---- SHARED USelectMenu STYLES ----
Apply via: <USelectMenu class="zine-select" :ui="{ content: 'tz-content', item: 'tz-item', input: 'tz-input' }" />
Classes are global (not scoped) because Nuxt UI portals the popup content to body. */
button.zine-select,
button.timezone-select {
display: flex !important;
width: 100%;
padding: 5px 8px !important;
font-family: "Commit Mono", monospace !important;
font-size: 13px !important;
color: var(--text-bright) !important;
background: var(--input-bg) !important;
border: 1px solid var(--border) !important;
border-radius: 0 !important;
box-shadow: none !important;
outline: none !important;
min-height: 0;
--tw-ring-shadow: 0 0 #0000;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-color: transparent;
}
button.zine-select:hover,
button.timezone-select:hover {
background: var(--input-bg) !important;
}
button.zine-select:focus,
button.zine-select:focus-visible,
button.zine-select[aria-expanded="true"],
button.timezone-select:focus,
button.timezone-select:focus-visible,
button.timezone-select[aria-expanded="true"] {
border-color: var(--candle) !important;
}
.tz-content {
background: var(--input-bg) !important;
border: 1px solid var(--border) !important;
border-radius: 0 !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12) !important;
--tw-ring-shadow: 0 0 #0000 !important;
--tw-ring-offset-shadow: 0 0 #0000 !important;
font-family: "Commit Mono", monospace !important;
}
.tz-input {
border-bottom: 1px dashed var(--border) !important;
}
.tz-input input {
font-family: "Commit Mono", monospace !important;
font-size: 13px !important;
color: var(--text-bright) !important;
background: transparent !important;
border-radius: 0 !important;
padding: 6px 8px !important;
box-shadow: none !important;
--tw-ring-shadow: 0 0 #0000 !important;
--tw-ring-offset-shadow: 0 0 #0000 !important;
}
.tz-item {
font-family: "Commit Mono", monospace !important;
font-size: 13px !important;
color: var(--text) !important;
border-radius: 0 !important;
padding: 6px 8px !important;
}
.tz-item::before {
border-radius: 0 !important;
}
.tz-item[data-highlighted]::before,
.tz-item[data-highlighted]:not([data-disabled])::before {
background: var(--surface-hover) !important;
}
.tz-item[data-highlighted],
.tz-item[data-highlighted]:not([data-disabled]) {
color: var(--text-bright) !important;
}
/* ---- MOBILE ---- */ /* ---- MOBILE ---- */
@media (max-width: 1023px) { @media (max-width: 1023px) {
body { body {

View file

@ -133,11 +133,11 @@
<div class="sidebar-meta"> <div class="sidebar-meta">
<ClientOnly> <ClientOnly>
Part of Part of
<a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br /> <a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br >
A Canadian nonprofit A Canadian nonprofit
<template #fallback> <template #fallback>
Part of Part of
<a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br /> <a href="https://babyghosts.org" target="_blank">Baby Ghosts</a><br >
A Canadian nonprofit A Canadian nonprofit
</template> </template>
</ClientOnly> </ClientOnly>
@ -199,7 +199,6 @@ const youItems = [
{ label: "Dashboard", path: "/member/dashboard" }, { label: "Dashboard", path: "/member/dashboard" },
{ label: "Profile", path: "/member/profile" }, { label: "Profile", path: "/member/profile" },
{ label: "Account", path: "/member/account" }, { label: "Account", path: "/member/account" },
{ label: "Activity Log", path: "/member/activity" },
]; ];
const exploreItems = [ const exploreItems = [

View file

@ -120,23 +120,29 @@
<form @submit.prevent="handleSubmit"> <form @submit.prevent="handleSubmit">
<div class="field"> <div class="field">
<label>Full Name</label> <label for="ticket-name">Full Name</label>
<input <input
id="ticket-name"
v-model="form.name" v-model="form.name"
name="name"
type="text" type="text"
autocomplete="name"
required required
:disabled="processing" :disabled="processing"
/> >
</div> </div>
<div class="field"> <div class="field">
<label>Email Address</label> <label for="ticket-email">Email Address</label>
<input <input
id="ticket-email"
v-model="form.email" v-model="form.email"
name="email"
type="email" type="email"
autocomplete="email"
required required
:disabled="processing" :disabled="processing"
/> >
</div> </div>
<p <p
@ -244,11 +250,13 @@ onMounted(async () => {
await fetchTicketInfo(); await fetchTicketInfo();
}); });
const fetchTicketInfo = async () => { const fetchTicketInfo = async (emailOverride = null) => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const effectiveEmail = emailOverride || props.userEmail;
// First check if this event requires a series pass // First check if this event requires a series pass
if (props.userEmail) { if (props.userEmail) {
try { try {
@ -284,7 +292,7 @@ const fetchTicketInfo = async () => {
} }
// Regular ticket availability check // Regular ticket availability check
const params = props.userEmail ? `?email=${props.userEmail}` : ""; const params = effectiveEmail ? `?email=${encodeURIComponent(effectiveEmail)}` : "";
const response = await $fetch( const response = await $fetch(
`/api/events/${props.eventId}/tickets/available${params}`, `/api/events/${props.eventId}/tickets/available${params}`,
); );
@ -326,15 +334,17 @@ const handleSubmit = async () => {
} }
} }
const body = {
name: form.value.name,
email: form.value.email,
};
if (transactionId) body.transactionId = transactionId;
const response = await $fetch( const response = await $fetch(
`/api/events/${props.eventId}/tickets/purchase`, `/api/events/${props.eventId}/tickets/purchase`,
{ {
method: "POST", method: "POST",
body: { body,
name: form.value.name,
email: form.value.email,
transactionId,
},
}, },
); );
@ -347,7 +357,7 @@ const handleSubmit = async () => {
}); });
emit("success", response); emit("success", response);
await fetchTicketInfo(); await fetchTicketInfo(form.value.email);
} catch (err) { } catch (err) {
console.error("Error purchasing ticket:", err); console.error("Error purchasing ticket:", err);

View file

@ -8,7 +8,6 @@
@click="$emit('update:modelValue', tier.amount)" @click="$emit('update:modelValue', tier.amount)"
> >
<span class="tier-amount">{{ tier.display }}</span> <span class="tier-amount">{{ tier.display }}</span>
<span class="tier-label">{{ tier.label }}</span>
</div> </div>
</div> </div>
</template> </template>
@ -40,7 +39,7 @@ defineEmits(["update:modelValue"]);
.tier-option { .tier-option {
flex: 1; flex: 1;
padding: 10px 8px; padding: 18px 8px;
text-align: center; text-align: center;
border: 1px dashed var(--border); border: 1px dashed var(--border);
background: var(--bg); background: var(--bg);
@ -67,30 +66,18 @@ defineEmits(["update:modelValue"]);
} }
.tier-amount { .tier-amount {
font-size: 16px; font-size: 24px;
font-weight: 600; font-weight: 600;
color: var(--text); color: var(--text);
font-family: "Brygada 1918", serif; font-family: "Brygada 1918", serif;
display: block; display: block;
line-height: 1.1;
} }
.tier-option.current .tier-amount { .tier-option.current .tier-amount {
color: var(--candle); color: var(--candle);
} }
.tier-label {
font-size: 9px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-faint);
display: block;
margin-top: 2px;
}
.tier-option.current .tier-label {
color: var(--candle-dim);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.tier-picker { .tier-picker {
flex-wrap: wrap; flex-wrap: wrap;

View file

@ -66,6 +66,14 @@
Board Channels Board Channels
</NuxtLink> </NuxtLink>
</li> </li>
<li>
<NuxtLink
to="/admin/site-content"
:class="{ active: route.path.startsWith('/admin/site-content') }"
>
Site Content
</NuxtLink>
</li>
</ul> </ul>
<div class="sidebar-section">Site</div> <div class="sidebar-section">Site</div>
@ -76,7 +84,7 @@
</div> </div>
<div class="sidebar-meta"> <div class="sidebar-meta">
<span class="admin-tag">admin</span><br /> <span class="admin-tag">admin</span><br >
<a href="#" @click.prevent="logout">Sign out</a> <a href="#" @click.prevent="logout">Sign out</a>
</div> </div>
</aside> </aside>
@ -170,6 +178,15 @@
Board Channels Board Channels
</NuxtLink> </NuxtLink>
</li> </li>
<li>
<NuxtLink
to="/admin/site-content"
:class="{ active: route.path.startsWith('/admin/site-content') }"
@click="isMobileMenuOpen = false"
>
Site Content
</NuxtLink>
</li>
</ul> </ul>
<div class="sidebar-section">Site</div> <div class="sidebar-section">Site</div>
@ -190,7 +207,7 @@
</div> </div>
<div class="sidebar-meta"> <div class="sidebar-meta">
<span class="admin-tag">admin</span><br /> <span class="admin-tag">admin</span><br >
<a href="#" @click.prevent="logout">Sign out</a> <a href="#" @click.prevent="logout">Sign out</a>
</div> </div>
</aside> </aside>

View file

@ -1,5 +1,9 @@
<template> <template>
<PageShell title="Bulletin Board" :subtitle="pageSubtitle"> <PageShell title="Bulletin Board" :subtitle="pageSubtitle">
<p class="page-intro">
Make offers and requests related to shared interests and cooperative
topics.
</p>
<div class="action-bar"> <div class="action-bar">
<button <button
v-if="cooperativeTags.length > 0" v-if="cooperativeTags.length > 0"
@ -204,6 +208,14 @@ onMounted(async () => {
</script> </script>
<style scoped> <style scoped>
.page-intro {
padding: 12px 24px 0;
color: var(--text-dim);
font-size: 13px;
line-height: 1.65;
max-width: 640px;
}
.action-bar { .action-bar {
padding: 12px 24px; padding: 12px 24px;
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);

View file

@ -11,9 +11,18 @@
<!-- FILTER BAR --> <!-- FILTER BAR -->
<FilterBar v-model="activeFilter" :filters="filterOptions"> <FilterBar v-model="activeFilter" :filters="filterOptions">
<label class="filter-toggle"> <button
<input v-model="includePastEvents" type="checkbox" /> Show past events type="button"
</label> class="past-toggle"
:class="{ active: includePastEvents }"
:aria-pressed="includePastEvents"
@click="includePastEvents = !includePastEvents"
>
<span class="past-toggle-box" aria-hidden="true">
<span v-if="includePastEvents" class="past-toggle-check">×</span>
</span>
Show past events
</button>
</FilterBar> </FilterBar>
<!-- EVENT LIST --> <!-- EVENT LIST -->
@ -53,6 +62,14 @@
<span :class="{ 'seats-warn': isAlmostFull(event) }"> <span :class="{ 'seats-warn': isAlmostFull(event) }">
{{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats {{ event.registeredCount || 0 }}/{{ event.maxAttendees }} seats
</span> </span>
<span v-if="isSoldOut(event)" class="capacity-badge sold-out"
>Sold out</span
>
<span
v-else-if="isAlmostFull(event)"
class="capacity-badge limited"
>Limited tickets</span
>
</template> </template>
<template v-else>Open</template> <template v-else>Open</template>
</span> </span>
@ -154,9 +171,15 @@ const formatLocation = (event) => {
return event.location; return event.location;
}; };
const isSoldOut = (event) => {
if (!event.maxAttendees) return false;
return (event.registeredCount || 0) >= event.maxAttendees;
};
const isAlmostFull = (event) => { const isAlmostFull = (event) => {
if (!event.maxAttendees) return false; if (!event.maxAttendees) return false;
return (event.registeredCount || 0) / event.maxAttendees > 0.8; if (isSoldOut(event)) return false;
return (event.registeredCount || 0) / event.maxAttendees >= 0.8;
}; };
</script> </script>
@ -289,10 +312,29 @@ const isAlmostFull = (event) => {
color: var(--text-faint); color: var(--text-faint);
white-space: nowrap; white-space: nowrap;
padding-top: 2px; padding-top: 2px;
display: inline-flex;
align-items: center;
gap: 6px;
} }
.seats-warn { .seats-warn {
color: var(--ember); color: var(--ember);
} }
.capacity-badge {
font-size: 9px;
letter-spacing: 0.07em;
text-transform: uppercase;
padding: 1px 5px;
border: 1px dashed currentColor;
line-height: 1.5;
white-space: nowrap;
}
.capacity-badge.limited {
color: var(--ember);
}
.capacity-badge.sold-out {
color: var(--text-faint);
border-style: solid;
}
.event-badges { .event-badges {
display: flex; display: flex;
@ -358,17 +400,43 @@ const isAlmostFull = (event) => {
} }
.filter-toggle { .past-toggle {
display: flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 8px;
margin-left: auto; margin-left: auto;
font-family: "Commit Mono", monospace;
font-size: 11px; font-size: 11px;
letter-spacing: 0.04em;
color: var(--text-faint); color: var(--text-faint);
background: transparent;
border: 1px dashed var(--border);
padding: 4px 10px;
cursor: pointer; cursor: pointer;
transition: all 0.15s;
} }
.filter-toggle input { .past-toggle:hover {
accent-color: var(--candle-dim); border-color: var(--candle-faint);
color: var(--text-dim);
}
.past-toggle.active {
border-color: var(--candle);
border-style: solid;
color: var(--candle);
}
.past-toggle-box {
display: inline-flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
border: 1px solid currentColor;
flex-shrink: 0;
}
.past-toggle-check {
font-size: 12px;
line-height: 1;
color: var(--candle);
} }
.empty { .empty {

View file

@ -87,18 +87,24 @@
> >
From the Wiki From the Wiki
</div> </div>
<h2>What is a cooperative studio?</h2> <template v-if="hasCustomWikiFeature">
<p> <h2>{{ wikiFeature.title || DEFAULT_WIKI_FEATURE_TITLE }}</h2>
A cooperative studio is a game development company owned and governed by <p v-for="(para, i) in customWikiParagraphs" :key="i">{{ para }}</p>
the people who work there. Decisions are made collectively. Profits are </template>
shared according to contribution, not ownership stake. <template v-else>
</p> <h2>What is a cooperative studio?</h2>
<p> <p>
The games industry is full of stories about crunch, layoffs, and studios A cooperative studio is a game development company owned and governed
that extract value from workers. Cooperatives are one alternative not by the people who work there. Decisions are made collectively. Profits
the only one, but one worth are shared according to contribution, not ownership stake.
<a href="https://wiki.ghostguild.org">practicing together</a>. </p>
</p> <p>
The games industry is full of stories about crunch, layoffs, and
studios that extract value from workers. Cooperatives are one
alternative not the only one, but one worth
<a href="https://wiki.ghostguild.org">practicing together</a>.
</p>
</template>
<p> <p>
<a href="https://wiki.ghostguild.org">Read more in the wiki &rarr;</a> <a href="https://wiki.ghostguild.org">Read more in the wiki &rarr;</a>
</p> </p>
@ -121,6 +127,25 @@ const { data: wikiArticles } = await useFetch("/api/wiki/recent", {
default: () => [], default: () => [],
}); });
const DEFAULT_WIKI_FEATURE_TITLE = "What is a cooperative studio?";
const { data: wikiFeature } = await useFetch(
"/api/site-content/homepage.wiki_feature",
{ default: () => ({ title: "", body: "" }) }
);
const hasCustomWikiFeature = computed(
() => !!wikiFeature.value?.body?.trim()
);
const customWikiParagraphs = computed(() => {
const body = wikiFeature.value?.body?.trim() || "";
return body
.split(/\n{2,}/)
.map((p) => p.trim())
.filter(Boolean);
});
const circleData = [ const circleData = [
{ {
value: "community", value: "community",

View file

@ -63,19 +63,12 @@
<ParchmentInset> <ParchmentInset>
<h2>How membership works</h2> <h2>How membership works</h2>
<ul> <ul>
<li> <li>Full access to the knowledge commons, Slack, and peer support</li>
Full access to the knowledge commons, events, Slack community, and <li>Free access to all Ghost Guild events</li>
peer support <li>One member, one vote</li>
</li> <li>Your circle reflects where you are, not rank</li>
<li>One member, one vote in all decisions</li> <li>Pay what you can ($0&ndash;$50+/month, separate from circle)</li>
<li>Your circle is where you are in your journey, not rank</li> <li>Higher contributions create solidarity spots for others</li>
<li>
Your contribution is what you can afford ($0--50+/month, separate
from your circle)
</li>
<li>
Higher contributions create solidarity spots for those who need them
</li>
</ul> </ul>
</ParchmentInset> </ParchmentInset>
@ -172,7 +165,7 @@
type="text" type="text"
placeholder="Your name" placeholder="Your name"
required required
/> >
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="join-email">Email Address</label> <label class="form-label" for="join-email">Email Address</label>
@ -183,7 +176,7 @@
type="email" type="email"
placeholder="you@example.com" placeholder="you@example.com"
required required
/> >
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Circle</label> <label class="form-label">Circle</label>
@ -195,7 +188,7 @@
type="radio" type="radio"
name="circle" name="circle"
value="community" value="community"
/> >
<label for="circle-community"> <label for="circle-community">
<span <span
class="circle-label-name" class="circle-label-name"
@ -212,7 +205,7 @@
type="radio" type="radio"
name="circle" name="circle"
value="founder" value="founder"
/> >
<label for="circle-founder"> <label for="circle-founder">
<span <span
class="circle-label-name" class="circle-label-name"
@ -229,7 +222,7 @@
type="radio" type="radio"
name="circle" name="circle"
value="practitioner" value="practitioner"
/> >
<label for="circle-practitioner"> <label for="circle-practitioner">
<span <span
class="circle-label-name" class="circle-label-name"
@ -245,21 +238,18 @@
<label class="form-label" for="join-contribution" <label class="form-label" for="join-contribution"
>Monthly Contribution</label >Monthly Contribution</label
> >
<select <USelectMenu
id="join-contribution" id="join-contribution"
v-model="form.contributionTier" v-model="form.contributionTier"
class="form-select" :items="contributionItems"
> value-key="value"
<option value="0">$0/mo -- I need support right now</option> :search-input="false"
<option value="5">$5/mo -- I can contribute</option> class="zine-select"
<option value="15"> :ui="{
$15/mo -- I can sustain the community (suggested) content: 'tz-content',
</option> item: 'tz-item',
<option value="30">$30/mo -- I can support others too</option> }"
<option value="50"> />
$50/mo -- I want to sponsor multiple members
</option>
</select>
</div> </div>
<div class="form-group"> <div class="form-group">
<button <button
@ -434,6 +424,15 @@ const circleOptions = getCircleOptions();
// Contribution options from central config // Contribution options from central config
const contributionOptions = getContributionOptions(); const contributionOptions = getContributionOptions();
// Minimal labels for the dropdown (tier descriptions live in the left column).
const contributionItems = [
{ value: "0", label: "$0/mo" },
{ value: "5", label: "$5/mo" },
{ value: "15", label: "$15/mo (suggested)" },
{ value: "30", label: "$30/mo" },
{ value: "50", label: "$50/mo" },
];
// Initialize composables // Initialize composables
const { const {
initializeHelcimPay, initializeHelcimPay,
@ -671,11 +670,12 @@ onUnmounted(() => {
position: relative; position: relative;
} }
:deep(.parchment-inset ul li::before) { :deep(.parchment-inset ul li::before) {
content: "--"; content: "";
position: absolute; position: absolute;
left: 0; left: 0;
color: var(--candle-dim); color: var(--candle-faint);
opacity: 0.5; font-size: 14px;
line-height: 1.4;
} }
.parchment-link { .parchment-link {

View file

@ -1,332 +0,0 @@
<template>
<PageShell
title="Activity Log"
subtitle="Your recent activity"
>
<ColumnsLayout cols="events-sidebar" :limit="5">
<ClientOnly>
<!-- Loading State -->
<div v-if="loading && !entries.length" class="state-box">
<div class="spinner"></div>
<p class="state-text">Loading activity...</p>
</div>
<!-- Timeline -->
<div v-else-if="entries.length" class="timeline-wrap">
<div class="timeline">
<div v-for="entry in entries" :key="entry._id" class="tl-item">
<div class="tl-dot">
<UIcon :name="getActivity(entry).icon" class="tl-icon" />
</div>
<div class="tl-time">{{ formatDate(entry.timestamp) }}</div>
<div class="tl-text">
<template v-if="getActivity(entry).link">
<span>{{ getActivity(entry).text.split(getActivity(entry).linkText)[0] }}</span>
<NuxtLink :to="getActivity(entry).link" class="tl-link">{{ getActivity(entry).linkText }}</NuxtLink>
</template>
<span v-else>{{ getActivity(entry).text }}</span>
<span v-if="entry.performedBy" class="tl-admin-badge">admin</span>
</div>
<!-- Email body expandable -->
<div v-if="entry.type === 'email_sent' && getActivity(entry).emailBody" class="tl-email">
<button class="tl-email-toggle" @click="toggleEmail(entry._id)">
{{ expandedEmails[entry._id] ? 'Hide email' : 'View email' }}
</button>
<div v-if="expandedEmails[entry._id]" class="dashed-box tl-email-body">
<pre>{{ getActivity(entry).emailBody }}</pre>
</div>
</div>
</div>
</div>
<!-- Load More -->
<div v-if="hasMore" class="load-more">
<button class="btn" :disabled="loadingMore" @click="loadMore">
{{ loadingMore ? 'Loading...' : 'Load More' }}
</button>
</div>
</div>
<!-- Empty State -->
<div v-else class="state-box">
<div class="state-icon">
<UIcon name="i-lucide-activity" />
</div>
<h2 class="state-heading">No activity yet</h2>
<p class="state-text">Your activity will appear here as you use the Guild</p>
</div>
<template #fallback>
<div class="state-box">
<div class="spinner"></div>
<p class="state-text">Loading activity...</p>
</div>
</template>
</ClientOnly>
</ColumnsLayout>
</PageShell>
</template>
<script setup>
import { formatActivity } from '~/utils/activityText'
const entries = ref([])
const loading = ref(false)
const loadingMore = ref(false)
const hasMore = ref(false)
const nextCursor = ref(null)
const expandedEmails = ref({})
const getActivity = (entry) => formatActivity(entry)
const toggleEmail = (id) => {
expandedEmails.value[id] = !expandedEmails.value[id]
}
const formatDate = (date) => {
const now = new Date()
const d = new Date(date)
const diffInSeconds = Math.floor((now - d) / 1000)
if (diffInSeconds < 60) return 'just now'
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} days ago`
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
})
}
const loadEntries = async () => {
loading.value = true
try {
const data = await $fetch('/api/members/me/activity', {
params: { limit: 20 }
})
entries.value = data.entries
hasMore.value = data.hasMore
nextCursor.value = data.nextCursor
} catch (err) {
console.error('Failed to load activity:', err)
} finally {
loading.value = false
}
}
const loadMore = async () => {
if (!nextCursor.value) return
loadingMore.value = true
try {
const data = await $fetch('/api/members/me/activity', {
params: { limit: 20, before: nextCursor.value }
})
entries.value.push(...data.entries)
hasMore.value = data.hasMore
nextCursor.value = data.nextCursor
} catch (err) {
console.error('Failed to load more activity:', err)
} finally {
loadingMore.value = false
}
}
onMounted(loadEntries)
useHead({ title: 'Activity Log - Ghost Guild' })
</script>
<style scoped>
/* ---- STATE BOXES ---- */
.state-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 32px;
text-align: center;
}
.state-icon {
width: 48px;
height: 48px;
border: 1px dashed var(--border);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
color: var(--text-faint);
font-size: 20px;
}
.state-heading {
font-family: 'Brygada 1918', serif;
font-size: 18px;
font-weight: 600;
color: var(--text-bright);
margin-bottom: 6px;
}
.state-text {
font-size: 12px;
color: var(--text-dim);
margin-bottom: 20px;
max-width: 320px;
}
.spinner {
width: 20px;
height: 20px;
border: 2px dashed var(--candle);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ---- TIMELINE ---- */
.timeline-wrap {
padding: 24px 32px 48px;
}
.timeline {
position: relative;
padding-left: 32px;
}
.timeline::before {
content: '';
position: absolute;
left: 11px;
top: 0;
bottom: 0;
width: 1px;
border-left: 1px dashed var(--border);
}
.tl-item {
position: relative;
padding: 0 0 24px;
}
.tl-item:last-child {
padding-bottom: 0;
}
.tl-dot {
position: absolute;
left: -32px;
top: 2px;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
border: 1px dashed var(--border);
font-size: 12px;
color: var(--text-dim);
}
.tl-icon {
width: 12px;
height: 12px;
}
.tl-time {
font-size: 11px;
color: var(--text-faint);
margin-bottom: 2px;
}
.tl-text {
font-size: 13px;
color: var(--text);
line-height: 1.5;
display: flex;
align-items: baseline;
gap: 4px;
flex-wrap: wrap;
}
.tl-link {
color: var(--candle);
text-decoration: none;
font-weight: 500;
}
.tl-link:hover {
text-decoration: underline;
}
.tl-admin-badge {
font-size: 10px;
color: var(--text-faint);
border: 1px dashed var(--border);
padding: 1px 5px;
margin-left: 4px;
}
/* ---- EMAIL EXPANDABLE ---- */
.tl-email {
margin-top: 4px;
}
.tl-email-toggle {
font-family: 'Commit Mono', monospace;
font-size: 11px;
color: var(--text-faint);
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color 0.15s;
}
.tl-email-toggle:hover {
color: var(--candle);
}
.tl-email-body {
margin-top: 6px;
padding: 12px;
font-size: 12px;
color: var(--text-dim);
line-height: 1.6;
max-height: 300px;
overflow-y: auto;
}
.tl-email-body pre {
white-space: pre-wrap;
word-break: break-word;
font-family: 'Commit Mono', monospace;
font-size: 12px;
margin: 0;
}
/* ---- LOAD MORE ---- */
.load-more {
display: flex;
justify-content: center;
padding-top: 8px;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 768px) {
.timeline-wrap {
padding: 20px 20px 40px;
}
.state-box {
padding: 48px 20px;
}
}
</style>

View file

@ -90,8 +90,8 @@
<strong>How to Subscribe to Your Calendar</strong> <strong>How to Subscribe to Your Calendar</strong>
<button <button
type="button" type="button"
@click="showCalendarInstructions = false"
class="ci-close" class="ci-close"
@click="showCalendarInstructions = false"
> >
&times; &times;
</button> </button>
@ -192,14 +192,14 @@
</div> </div>
<div class="content-block"> <div class="content-block">
<div class="section-label">Community</div> <div class="section-label">Bulletin Board</div>
<DashedBox> <DashedBox>
<p class="peer-text"> <p class="peer-text">
Connect with other members through shared interests and Make offers and requests related to shared interests and
cooperative topics. cooperative topics.
</p> </p>
<NuxtLink to="/board" class="section-link"> <NuxtLink to="/board" class="section-link">
Browse the board &rarr; Browse the Bulletin Board &rarr;
</NuxtLink> </NuxtLink>
</DashedBox> </DashedBox>
</div> </div>

View file

@ -25,10 +25,7 @@
<template v-else> <template v-else>
<!-- PAGE HEADER --> <!-- PAGE HEADER -->
<PageHeader <PageHeader title="Edit Profile">
title="Edit Profile"
subtitle="How you appear to other members"
>
<NuxtLink <NuxtLink
v-if=" v-if="
memberId && memberId &&
@ -234,6 +231,44 @@
</div> </div>
</div> </div>
</PageSection> </PageSection>
<PageSection divider="top">
<div class="section-label">Recent Activity</div>
<div v-if="activityLoading" class="activity-empty">
Loading activity
</div>
<ul v-else-if="recentActivity.length" class="activity-list">
<li
v-for="entry in recentActivity"
:key="entry._id"
class="activity-item"
>
<div class="activity-time">
{{ formatActivityTime(entry.timestamp) }}
</div>
<div class="activity-text">
<template v-if="formatActivity(entry).link">
<span>{{
formatActivity(entry).text.split(
formatActivity(entry).linkText,
)[0]
}}</span>
<NuxtLink
:to="formatActivity(entry).link"
class="activity-link"
>
{{ formatActivity(entry).linkText }}
</NuxtLink>
</template>
<span v-else>{{ formatActivity(entry).text }}</span>
</div>
</li>
</ul>
<div v-else class="activity-empty">
Your activity will appear here as you use the Guild.
</div>
</PageSection>
</template> </template>
</ColumnsLayout> </ColumnsLayout>
@ -269,6 +304,7 @@
<script setup> <script setup>
import { MEMBER_STATUSES } from "~/composables/useMemberStatus"; import { MEMBER_STATUSES } from "~/composables/useMemberStatus";
import { TIMEZONE_OPTIONS } from "~/config/timezones"; import { TIMEZONE_OPTIONS } from "~/config/timezones";
import { formatActivity } from "~/utils/activityText";
definePageMeta({ definePageMeta({
middleware: "auth", middleware: "auth",
@ -333,13 +369,8 @@ const timezoneItems = computed(() => {
const notificationToggles = [ const notificationToggles = [
{ {
key: "events", key: "events",
label: "Event reminders", label: "Registration & cancellation emails",
sub: "Get notified about upcoming events", sub: "Confirmation when you register for an event, and notice if it's cancelled",
},
{
key: "updates",
label: "Community updates",
sub: "New posts from members you follow",
}, },
]; ];
@ -370,7 +401,6 @@ const formData = reactive({
boardSlackHandle: "", boardSlackHandle: "",
notifications: { notifications: {
events: true, events: true,
updates: true,
}, },
}); });
@ -378,6 +408,39 @@ const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const initialData = ref(null); const initialData = ref(null);
const recentActivity = ref([]);
const activityLoading = ref(false);
const formatActivityTime = (date) => {
const now = new Date();
const d = new Date(date);
const diff = Math.floor((now - d) / 1000);
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
};
const loadRecentActivity = async () => {
activityLoading.value = true;
try {
const data = await $fetch("/api/members/me/activity", {
params: { limit: 5 },
});
recentActivity.value = data.entries || [];
} catch (err) {
console.error("Failed to load activity:", err);
recentActivity.value = [];
} finally {
activityLoading.value = false;
}
};
const memberId = computed(() => memberData.value?._id || memberData.value?.id); const memberId = computed(() => memberData.value?._id || memberData.value?.id);
const hasChanges = computed(() => { const hasChanges = computed(() => {
@ -405,7 +468,6 @@ const loadProfile = () => {
const notifs = memberData.value.notifications || {}; const notifs = memberData.value.notifications || {};
formData.notifications.events = notifs.events ?? true; formData.notifications.events = notifs.events ?? true;
formData.notifications.updates = notifs.updates ?? true;
initialData.value = JSON.parse(JSON.stringify(formData)); initialData.value = JSON.parse(JSON.stringify(formData));
}; };
@ -458,7 +520,10 @@ onMounted(async () => {
loadProfile(); loadProfile();
if (memberId.value) { if (memberId.value) {
await fetchPosts({ author: memberId.value }); await Promise.allSettled([
fetchPosts({ author: memberId.value }),
loadRecentActivity(),
]);
} }
}); });
@ -712,6 +777,41 @@ useHead({
color: var(--ember); color: var(--ember);
} }
/* ---- RECENT ACTIVITY ---- */
.activity-list {
list-style: none;
margin: 0;
padding: 0;
border-top: 1px dashed var(--border);
}
.activity-item {
padding: 8px 0;
border-bottom: 1px dashed var(--border);
font-size: 12px;
color: var(--text);
line-height: 1.5;
}
.activity-time {
font-size: 10px;
color: var(--text-faint);
margin-bottom: 2px;
}
.activity-text {
color: var(--text);
}
.activity-link {
color: var(--candle);
text-decoration: none;
}
.activity-link:hover {
text-decoration: underline;
}
.activity-empty {
font-size: 12px;
color: var(--text-faint);
padding: 10px 0;
}
/* ---- DISABLED BUTTON ---- */ /* ---- DISABLED BUTTON ---- */
.btn:disabled { .btn:disabled {
opacity: 0.4; opacity: 0.4;
@ -743,84 +843,3 @@ useHead({
} }
</style> </style>
<style>
/* Non-scoped: targets USelectMenu button root which does not inherit scoped data attribute. */
button.timezone-select {
display: flex !important;
width: 100%;
padding: 5px 8px !important;
font-family: "Commit Mono", monospace !important;
font-size: 13px !important;
color: var(--text-bright) !important;
background: var(--input-bg) !important;
border: 1px solid var(--border) !important;
border-radius: 0 !important;
box-shadow: none !important;
outline: none !important;
min-height: 0;
--tw-ring-shadow: 0 0 #0000;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-color: transparent;
}
button.timezone-select:hover {
background: var(--input-bg) !important;
}
button.timezone-select:focus,
button.timezone-select:focus-visible,
button.timezone-select[aria-expanded="true"] {
border-color: var(--candle) !important;
}
/* Popup content (portalled to body) */
.tz-content {
background: var(--input-bg) !important;
border: 1px solid var(--border) !important;
border-radius: 0 !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12) !important;
--tw-ring-shadow: 0 0 #0000 !important;
--tw-ring-offset-shadow: 0 0 #0000 !important;
font-family: "Commit Mono", monospace !important;
}
/* Search input wrapper inside popup */
.tz-input {
border-bottom: 1px dashed var(--border) !important;
}
.tz-input input {
font-family: "Commit Mono", monospace !important;
font-size: 13px !important;
color: var(--text-bright) !important;
background: transparent !important;
border-radius: 0 !important;
padding: 6px 8px !important;
box-shadow: none !important;
--tw-ring-shadow: 0 0 #0000 !important;
--tw-ring-offset-shadow: 0 0 #0000 !important;
}
/* Option rows */
.tz-item {
font-family: "Commit Mono", monospace !important;
font-size: 13px !important;
color: var(--text) !important;
border-radius: 0 !important;
padding: 6px 8px !important;
}
.tz-item::before {
border-radius: 0 !important;
}
.tz-item[data-highlighted]::before,
.tz-item[data-highlighted]:not([data-disabled])::before {
background: var(--surface-hover) !important;
}
.tz-item[data-highlighted],
.tz-item[data-highlighted]:not([data-disabled]) {
color: var(--text-bright) !important;
}
</style>

View file

@ -1,8 +1,5 @@
<template> <template>
<PageShell <PageShell title="Members">
title="Members"
:subtitle="pageSubtitle"
>
<!-- Filter Bar --> <!-- Filter Bar -->
<div class="filter-bar"> <div class="filter-bar">
<input <input
@ -11,34 +8,25 @@
class="filter-search" class="filter-search"
placeholder="Search members..." placeholder="Search members..."
@input="debouncedSearch" @input="debouncedSearch"
/>
<select
v-model="selectedCircle"
class="filter-select"
@change="loadMembers"
> >
<option <USelectMenu
v-for="opt in circleOptions" v-model="selectedCircle"
:key="opt.value" :items="circleOptions"
:value="opt.value" value-key="value"
> :search-input="false"
{{ opt.label }} class="zine-select circle-select"
</option> :ui="{
</select> content: 'tz-content',
<label class="filter-toggle"> item: 'tz-item',
<input }"
type="checkbox" @update:model-value="loadMembers"
:checked="peerSupportFilter === 'true'" />
@change="togglePeerSupport" <button
/> v-if="craftTagOptions.length > 0"
Offering support type="button"
</label> class="drawer-btn"
<span class="filter-count">Showing {{ totalCount }} member{{ totalCount === 1 ? '' : 's' }} across 3 circles</span> @click="showTagsDrawer = !showTagsDrawer"
</div> >
<!-- Tags Drawer Toggle -->
<div v-if="craftTagOptions.length > 0" class="tags-drawer-toggle">
<button type="button" class="drawer-btn" @click="showTagsDrawer = !showTagsDrawer">
Tags... Tags...
<span v-if="directoryCraftTags.length > 0" class="tag-count-badge">{{ directoryCraftTags.length }}</span> <span v-if="directoryCraftTags.length > 0" class="tag-count-badge">{{ directoryCraftTags.length }}</span>
</button> </button>
@ -76,10 +64,6 @@
{{ circleLabels[selectedCircle] }} {{ circleLabels[selectedCircle] }}
<button type="button" @click="clearCircleFilter">&times;</button> <button type="button" @click="clearCircleFilter">&times;</button>
</span> </span>
<span v-if="peerSupportFilter === 'true'" class="af-tag">
Offering Support
<button type="button" @click="clearPeerSupportFilter">&times;</button>
</span>
<span v-for="slug in directoryCraftTags" :key="'c-' + slug" class="af-tag"> <span v-for="slug in directoryCraftTags" :key="'c-' + slug" class="af-tag">
{{ craftTagLabel(slug) }} {{ craftTagLabel(slug) }}
<button type="button" @click="toggleDirectoryCraftTag(slug)">&times;</button> <button type="button" @click="toggleDirectoryCraftTag(slug)">&times;</button>
@ -108,7 +92,7 @@
:src="`/ghosties/Ghost-${capitalize(member.avatar)}.png`" :src="`/ghosties/Ghost-${capitalize(member.avatar)}.png`"
:alt="member.name" :alt="member.name"
class="mc-avatar-img" class="mc-avatar-img"
/> >
<span v-else>{{ getInitials(member.name) }}</span> <span v-else>{{ getInitials(member.name) }}</span>
</div> </div>
<div class="mc-info"> <div class="mc-info">
@ -130,16 +114,16 @@
v-if="member.bio" v-if="member.bio"
class="mc-bio" class="mc-bio"
v-html="renderMarkdown(member.bio)" v-html="renderMarkdown(member.bio)"
></div> />
<div v-if="member.craftTags?.length > 0" class="mc-tags"> <div v-if="member.craftTags?.length > 0" class="mc-tags">
<span class="tag-label">Craft:</span> <span class="tag-label">Craft:</span>
<span <span
v-for="tag in member.craftTags.slice(0, 5)" v-for="tag in member.craftTags.slice(0, 3)"
:key="tag" :key="tag"
class="skill-tag" class="skill-tag"
>{{ craftTagLabel(tag) }}</span> >{{ craftTagLabel(tag) }}</span>
<span v-if="member.craftTags.length > 5" class="tag-overflow">+{{ member.craftTags.length - 5 }}</span> <span v-if="member.craftTags.length > 3" class="tag-overflow">+{{ member.craftTags.length - 3 }} more</span>
</div> </div>
</div> </div>
</div> </div>
@ -159,7 +143,6 @@
<script setup> <script setup>
definePageMeta({ middleware: ['members-auth'] }) definePageMeta({ middleware: ['members-auth'] })
const route = useRoute()
const { render: renderMarkdown } = useMarkdown() const { render: renderMarkdown } = useMarkdown()
// ---- Directory state ---- // ---- Directory state ----
@ -168,7 +151,6 @@ const totalCount = ref(0)
const loading = ref(true) const loading = ref(true)
const searchQuery = ref('') const searchQuery = ref('')
const selectedCircle = ref('all') const selectedCircle = ref('all')
const peerSupportFilter = ref('all')
const directoryCraftTags = ref([]) const directoryCraftTags = ref([])
const craftTagOptions = ref([]) const craftTagOptions = ref([])
const showAllTags = ref(false) const showAllTags = ref(false)
@ -218,14 +200,9 @@ const visibleTagOptions = computed(() =>
const hasActiveFilters = computed(() => const hasActiveFilters = computed(() =>
(selectedCircle.value && selectedCircle.value !== 'all') || (selectedCircle.value && selectedCircle.value !== 'all') ||
peerSupportFilter.value === 'true' ||
directoryCraftTags.value.length > 0 directoryCraftTags.value.length > 0
) )
const pageSubtitle = computed(() =>
`${totalCount.value} member${totalCount.value === 1 ? '' : 's'} across 3 circles`
)
// ---- Load members ---- // ---- Load members ----
const loadMembers = async () => { const loadMembers = async () => {
loading.value = true loading.value = true
@ -233,7 +210,6 @@ const loadMembers = async () => {
const params = {} const params = {}
if (searchQuery.value) params.search = searchQuery.value if (searchQuery.value) params.search = searchQuery.value
if (selectedCircle.value && selectedCircle.value !== 'all') params.circle = selectedCircle.value if (selectedCircle.value && selectedCircle.value !== 'all') params.circle = selectedCircle.value
if (peerSupportFilter.value === 'true') params.peerSupport = 'true'
if (directoryCraftTags.value.length === 1) params.craftTag = directoryCraftTags.value[0] if (directoryCraftTags.value.length === 1) params.craftTag = directoryCraftTags.value[0]
const data = await $fetch('/api/members/directory', { params }) const data = await $fetch('/api/members/directory', { params })
@ -266,11 +242,6 @@ const loadTagOptions = async () => {
} }
// ---- Filter helpers ---- // ---- Filter helpers ----
const togglePeerSupport = (e) => {
peerSupportFilter.value = e.target.checked ? 'true' : 'all'
loadMembers()
}
let searchTimeout let searchTimeout
const debouncedSearch = () => { const debouncedSearch = () => {
clearTimeout(searchTimeout) clearTimeout(searchTimeout)
@ -294,15 +265,9 @@ const clearCircleFilter = () => {
loadMembers() loadMembers()
} }
const clearPeerSupportFilter = () => {
peerSupportFilter.value = 'all'
loadMembers()
}
const clearAllFilters = () => { const clearAllFilters = () => {
searchQuery.value = '' searchQuery.value = ''
selectedCircle.value = 'all' selectedCircle.value = 'all'
peerSupportFilter.value = 'all'
directoryCraftTags.value = [] directoryCraftTags.value = []
showTagsDrawer.value = false showTagsDrawer.value = false
loadMembers() loadMembers()
@ -325,10 +290,6 @@ useHead({
// ---- Init ---- // ---- Init ----
onMounted(async () => { onMounted(async () => {
if (route.query.peerSupport === 'true') {
peerSupportFilter.value = 'true'
}
await loadTagOptions() await loadTagOptions()
await loadMembers() await loadMembers()
}) })
@ -363,51 +324,15 @@ onMounted(async () => {
border-color: var(--candle-faint); border-color: var(--candle-faint);
} }
.filter-select { /* Constrain the circle USelectMenu button width so it doesn't stretch. */
font-family: "Commit Mono", monospace; :deep(.circle-select) {
font-size: 11px; width: auto !important;
padding: 5px 10px; min-width: 150px;
border: 1px dashed var(--border);
background: transparent;
color: var(--text-dim);
cursor: pointer;
outline: none;
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%238a7e6a' stroke-width='1.2'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
padding-right: 26px;
}
.filter-select:focus {
border-color: var(--candle-faint);
}
.filter-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
}
.filter-toggle input {
accent-color: var(--candle-dim);
}
.filter-count {
margin-left: auto;
font-size: 11px;
color: var(--text-faint);
} }
/* ---- TAGS DRAWER ---- */ /* ---- TAGS DRAWER ---- */
.tags-drawer-toggle {
padding: 8px 24px;
border-bottom: 1px dashed var(--border);
}
.drawer-btn { .drawer-btn {
margin-left: auto;
font-family: "Commit Mono", monospace; font-family: "Commit Mono", monospace;
font-size: 11px; font-size: 11px;
color: var(--text-dim); color: var(--text-dim);
@ -579,7 +504,7 @@ onMounted(async () => {
.mc-avatar { .mc-avatar {
width: 32px; width: 32px;
height: 32px; height: 32px;
background: var(--surface); background: transparent;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -720,15 +645,12 @@ onMounted(async () => {
align-items: stretch; align-items: stretch;
padding: 14px 20px; padding: 14px 20px;
} }
.filter-count { .drawer-btn {
margin-left: 0; margin-left: 0;
} }
.skills-bar { .skills-bar {
padding: 10px 20px; padding: 10px 20px;
} }
.tags-drawer-toggle {
padding: 8px 20px;
}
.active-filters { .active-filters {
padding: 8px 20px; padding: 8px 20px;
} }
@ -748,14 +670,5 @@ onMounted(async () => {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
} }
.filter-select {
flex: 1 1 45%;
}
.filter-toggle {
flex: 1 1 45%;
}
.filter-count {
flex-basis: 100%;
}
} }
</style> </style>

View file

@ -18,12 +18,13 @@ test.describe('Events list page', () => {
test('past events toggle exists and can be checked', async ({ page }) => { test('past events toggle exists and can be checked', async ({ page }) => {
await page.goto('/events') await page.goto('/events')
const checkbox = page.locator('input[type="checkbox"]') await page.waitForLoadState('networkidle')
await expect(checkbox).toBeVisible() const toggle = page.locator('.past-toggle')
await expect(page.locator('text=Show past events')).toBeVisible() await expect(toggle).toBeVisible()
await expect(toggle).toContainText('Show past events')
await checkbox.check() await toggle.click()
await expect(checkbox).toBeChecked() await expect(toggle).toHaveClass(/active/)
// Page should still render without errors after toggling // Page should still render without errors after toggling
await expect(page.locator('h1', { hasText: 'Events' })).toBeVisible() await expect(page.locator('h1', { hasText: 'Events' })).toBeVisible()
@ -44,7 +45,7 @@ test.describe('Events list page', () => {
await page.goto('/events') await page.goto('/events')
// Check the past events toggle so we see all events // Check the past events toggle so we see all events
await page.locator('input[type="checkbox"]').check() await page.locator('.past-toggle').click()
const eventLinks = page.locator('.event-row a') const eventLinks = page.locator('.event-row a')
const count = await eventLinks.count() const count = await eventLinks.count()

View file

@ -83,7 +83,8 @@ test.describe('Join page — member signup flow', () => {
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 }) await page.locator('#circle-community').check({ force: true })
await page.locator('#join-contribution').selectOption('0') await page.locator('#join-contribution').click()
await page.getByRole('option', { name: '$0/mo' }).click()
await expect(page.locator('.form-submit')).toBeEnabled() await expect(page.locator('.form-submit')).toBeEnabled()
@ -108,7 +109,8 @@ test.describe('Join page — member signup flow', () => {
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('#circle-community').check({ force: true })
await page.locator('#join-contribution').selectOption('0') await page.locator('#join-contribution').click()
await page.getByRole('option', { name: '$0/mo' }).click()
await page.locator('.form-submit').click() await page.locator('.form-submit').click()
// Should show an error about the email already existing // Should show an error about the email already existing

View file

@ -25,7 +25,6 @@ const authenticatedPages = [
{ name: 'admin-events-create', path: '/admin/events/create' }, { name: 'admin-events-create', path: '/admin/events/create' },
// New authenticated pages // New authenticated pages
{ name: 'member-account', path: '/member/account' }, { name: 'member-account', path: '/member/account' },
{ name: 'member-activity', path: '/member/activity' },
{ name: 'connections', path: '/connections' }, { name: 'connections', path: '/connections' },
{ name: 'admin-dashboard', path: '/admin' }, { name: 'admin-dashboard', path: '/admin' },
] ]

View file

@ -109,8 +109,9 @@ export default defineEventHandler(async (event) => {
// Complete ticket purchase (updates sold/reserved counts) // Complete ticket purchase (updates sold/reserved counts)
await completeTicketPurchase(eventData, ticketInfo.ticketType); await completeTicketPurchase(eventData, ticketInfo.ticketType);
// Save event with registration // Save event with registration; skip validators to avoid tripping on
await eventData.save(); // legacy location data unrelated to this write.
await eventData.save({ validateBeforeSave: false });
// Send confirmation email // Send confirmation email
try { try {

View file

@ -78,7 +78,6 @@ const memberSchema = new mongoose.Schema({
notifications: { notifications: {
events: { type: Boolean, default: true }, events: { type: Boolean, default: true },
updates: { type: Boolean, default: true },
}, },
inviteEmailSent: { type: Boolean, default: false }, inviteEmailSent: { type: Boolean, default: false },

View file

@ -27,8 +27,7 @@ export const memberProfileUpdateSchema = z.object({
}).optional(), }).optional(),
showInDirectory: z.boolean().optional(), showInDirectory: z.boolean().optional(),
notifications: z.object({ notifications: z.object({
events: z.boolean().optional(), events: z.boolean().optional()
updates: z.boolean().optional()
}).optional(), }).optional(),
craftTags: z.array(z.string().max(100)).max(16).optional(), craftTags: z.array(z.string().max(100)).max(16).optional(),
boardSlackHandle: z.string().max(200).optional() boardSlackHandle: z.string().max(200).optional()
@ -416,3 +415,12 @@ export const adminAlertRestoreSchema = z.object({
.min(1) .min(1)
.max(ADMIN_ALERT_TYPES.length) .max(ADMIN_ALERT_TYPES.length)
}) })
// --- Site content (key/value editable copy blocks) ---
export const SITE_CONTENT_KEYS = ['homepage.wiki_feature']
export const siteContentUpsertSchema = z.object({
title: z.string().max(300).optional(),
body: z.string().max(5000).optional()
})

View file

@ -292,7 +292,7 @@ export const releaseTicket = async (event, ticketType) => {
); );
} }
await event.save(); await event.save({ validateBeforeSave: false });
}; };
/** /**
@ -309,7 +309,7 @@ export const completeTicketPurchase = async (event, ticketType) => {
event.tickets.public.sold = (event.tickets.public.sold || 0) + 1; event.tickets.public.sold = (event.tickets.public.sold || 0) + 1;
} }
await event.save(); await event.save({ validateBeforeSave: false });
}; };
/** /**