Member/Ecology revamp.
Some checks failed
Test / vitest (push) Failing after 7m23s
Test / playwright (push) Has been skipped
Test / visual (push) Has been skipped
Test / Notify on failure (push) Successful in 2s

This commit is contained in:
Jennie Robinson Faber 2026-04-14 09:25:09 +01:00
parent fc7ec52574
commit 59d6e97787
31 changed files with 1763 additions and 1010 deletions

1
.gitignore vendored
View file

@ -33,3 +33,4 @@ e2e/.auth/
# Worktrees # Worktrees
.worktrees/ .worktrees/
.claude/worktrees/

View file

@ -26,7 +26,7 @@ defineProps({
default: () => [ default: () => [
{ value: 'community', label: 'Community', description: 'Learning together, exploring cooperative models' }, { value: 'community', label: 'Community', description: 'Learning together, exploring cooperative models' },
{ value: 'founder', label: 'Founder', description: 'Actively building a cooperative studio' }, { value: 'founder', label: 'Founder', description: 'Actively building a cooperative studio' },
{ value: 'practitioner', label: 'Practitioner', description: 'Experienced in cooperative business' }, { value: 'practitioner', label: 'Practitioner', description: 'Experienced in cooperative practice' },
], ],
}, },
}) })

View file

@ -53,6 +53,7 @@ if (props.cols === 'events-sidebar') {
/* cols="events-sidebar" */ /* cols="events-sidebar" */
.columns-events-sidebar { .columns-events-sidebar {
grid-template-columns: 1fr 200px; grid-template-columns: 1fr 200px;
flex: 1;
} }
/* Ensure grid children don't overflow */ /* Ensure grid children don't overflow */
@ -60,11 +61,14 @@ if (props.cols === 'events-sidebar') {
min-width: 0; min-width: 0;
} }
/* Dashed divider: right border on the first column child */ /* Dashed divider: right border on the first column child (except events-sidebar, which owns its own border-left) */
.divider-dashed .col:first-child, .divider-dashed .col:first-child,
.divider-dashed .col-main { .divider-dashed .col-main {
border-right: 1px dashed var(--border); border-right: 1px dashed var(--border);
} }
.divider-dashed.columns-events-sidebar .col-main {
border-right: none;
}
/* Responsive collapse at 1024px (default) */ /* Responsive collapse at 1024px (default) */
.collapse-1024 { .collapse-1024 {

View file

@ -57,8 +57,8 @@
{{ memberData.name }} {{ memberData.name }}
</NuxtLink> </NuxtLink>
</template> </template>
<template v-else> A cooperative for game developers </template> <template v-else> The Baby Ghosts member program </template>
<template #fallback> A cooperative for game developers </template> <template #fallback> The Baby Ghosts member program </template>
</ClientOnly> </ClientOnly>
</slot> </slot>
</span> </span>

View file

@ -4,137 +4,149 @@
*/ */
export const MEMBER_STATUSES = { export const MEMBER_STATUSES = {
PENDING_PAYMENT: 'pending_payment', PENDING_PAYMENT: "pending_payment",
ACTIVE: 'active', ACTIVE: "active",
SUSPENDED: 'suspended', SUSPENDED: "suspended",
CANCELLED: 'cancelled', CANCELLED: "cancelled",
} };
export const MEMBER_STATUS_CONFIG = { export const MEMBER_STATUS_CONFIG = {
pending_payment: { pending_payment: {
label: 'Payment Pending', label: "Payment Pending",
color: 'orange', color: "orange",
bgColor: 'bg-orange-500/10', bgColor: "bg-orange-500/10",
borderColor: 'border-orange-500/30', borderColor: "border-orange-500/30",
textColor: 'text-orange-300', textColor: "text-orange-300",
icon: 'heroicons:exclamation-triangle', icon: "heroicons:exclamation-triangle",
severity: 'warning', severity: "warning",
canRSVP: false, canRSVP: false,
canAccessMembers: true, canAccessMembers: true,
canPeerSupport: false, canPeerSupport: false,
}, },
active: { active: {
label: 'Active Member', label: "Active Member",
color: 'green', color: "green",
bgColor: 'bg-green-500/10', bgColor: "bg-green-500/10",
borderColor: 'border-green-500/30', borderColor: "border-green-500/30",
textColor: 'text-green-300', textColor: "text-green-300",
icon: 'heroicons:check-circle', icon: "heroicons:check-circle",
severity: 'success', severity: "success",
canRSVP: true, canRSVP: true,
canAccessMembers: true, canAccessMembers: true,
canPeerSupport: true, canPeerSupport: true,
}, },
suspended: { suspended: {
label: 'Membership Suspended', label: "Membership Suspended",
color: 'red', color: "red",
bgColor: 'bg-red-500/10', bgColor: "bg-red-500/10",
borderColor: 'border-red-500/30', borderColor: "border-red-500/30",
textColor: 'text-red-300', textColor: "text-red-300",
icon: 'heroicons:no-symbol', icon: "heroicons:no-symbol",
severity: 'error', severity: "error",
canRSVP: false, canRSVP: false,
canAccessMembers: false, canAccessMembers: false,
canPeerSupport: false, canPeerSupport: false,
}, },
cancelled: { cancelled: {
label: 'Membership Cancelled', label: "Membership Cancelled",
color: 'gray', color: "gray",
bgColor: 'bg-gray-500/10', bgColor: "bg-gray-500/10",
borderColor: 'border-gray-500/30', borderColor: "border-gray-500/30",
textColor: 'text-gray-300', textColor: "text-gray-300",
icon: 'heroicons:x-circle', icon: "heroicons:x-circle",
severity: 'error', severity: "error",
canRSVP: false, canRSVP: false,
canAccessMembers: false, canAccessMembers: false,
canPeerSupport: false, canPeerSupport: false,
}, },
} };
export const useMemberStatus = () => { export const useMemberStatus = () => {
const { memberData } = useAuth() const { memberData } = useAuth();
// Get current member status // Get current member status
const status = computed(() => memberData.value?.status || MEMBER_STATUSES.PENDING_PAYMENT) const status = computed(
() => memberData.value?.status || MEMBER_STATUSES.PENDING_PAYMENT,
);
// Get status configuration // Get status configuration
const statusConfig = computed(() => MEMBER_STATUS_CONFIG[status.value] || MEMBER_STATUS_CONFIG.pending_payment) const statusConfig = computed(
() =>
MEMBER_STATUS_CONFIG[status.value] ||
MEMBER_STATUS_CONFIG.pending_payment,
);
// Helper methods // Helper methods
const isActive = computed(() => status.value === MEMBER_STATUSES.ACTIVE) const isActive = computed(() => status.value === MEMBER_STATUSES.ACTIVE);
const isPendingPayment = computed(() => status.value === MEMBER_STATUSES.PENDING_PAYMENT) const isPendingPayment = computed(
const isSuspended = computed(() => status.value === MEMBER_STATUSES.SUSPENDED) () => status.value === MEMBER_STATUSES.PENDING_PAYMENT,
const isCancelled = computed(() => status.value === MEMBER_STATUSES.CANCELLED) );
const isInactive = computed(() => !isActive.value) const isSuspended = computed(
() => status.value === MEMBER_STATUSES.SUSPENDED,
);
const isCancelled = computed(
() => status.value === MEMBER_STATUSES.CANCELLED,
);
const isInactive = computed(() => !isActive.value);
// Check if member can perform action // Check if member can perform action
const canRSVP = computed(() => statusConfig.value.canRSVP) const canRSVP = computed(() => statusConfig.value.canRSVP);
const canAccessMembers = computed(() => statusConfig.value.canAccessMembers) const canAccessMembers = computed(() => statusConfig.value.canAccessMembers);
const canPeerSupport = computed(() => statusConfig.value.canPeerSupport) const canPeerSupport = computed(() => statusConfig.value.canPeerSupport);
// Get action button text and link based on status // Get action button text and link based on status
const getNextAction = () => { const getNextAction = () => {
if (isPendingPayment.value) { if (isPendingPayment.value) {
return { return {
label: 'Complete Payment', label: "Complete Payment",
link: '/member/profile#account', link: "/member/account",
icon: 'heroicons:credit-card', icon: "heroicons:credit-card",
color: 'orange', color: "orange",
} };
} }
if (isCancelled.value) { if (isCancelled.value) {
return { return {
label: 'Reactivate Membership', label: "Reactivate Membership",
link: '/member/profile#account', link: "/member/account",
icon: 'heroicons:arrow-path', icon: "heroicons:arrow-path",
color: 'blue', color: "blue",
} };
} }
if (isSuspended.value) { if (isSuspended.value) {
return { return {
label: 'Contact Support', label: "Contact Support",
link: 'mailto:support@ghostguild.org', link: "mailto:support@ghostguild.org",
icon: 'heroicons:envelope', icon: "heroicons:envelope",
color: 'gray', color: "gray",
} };
} }
return null return null;
} };
// Get banner message based on status // Get banner message based on status
const getBannerMessage = () => { const getBannerMessage = () => {
if (isPendingPayment.value) { if (isPendingPayment.value) {
return 'Your membership is pending payment. Please complete your payment to unlock full features.' return "Your membership is pending payment. Please complete your payment to unlock full features.";
} }
if (isSuspended.value) { if (isSuspended.value) {
return 'Your membership has been suspended. Please contact support to reactivate your account.' return "Your membership has been suspended. Please contact support to reactivate your account.";
} }
if (isCancelled.value) { if (isCancelled.value) {
return 'Your membership has been cancelled. Would you like to reactivate?' return "Your membership has been cancelled. Would you like to reactivate?";
} }
return null return null;
} };
// Get RSVP restriction message // Get RSVP restriction message
const getRSVPMessage = () => { const getRSVPMessage = () => {
if (isPendingPayment.value) { if (isPendingPayment.value) {
return 'Complete your payment to register for events' return "Complete your payment to register for events";
} }
if (isSuspended.value || isCancelled.value) { if (isSuspended.value || isCancelled.value) {
return 'Your membership status prevents RSVP. Please reactivate your account.' return "Your membership status prevents RSVP. Please reactivate your account.";
} }
return null return null;
} };
return { return {
status, status,
@ -151,5 +163,5 @@ export const useMemberStatus = () => {
getBannerMessage, getBannerMessage,
getRSVPMessage, getRSVPMessage,
MEMBER_STATUSES, MEMBER_STATUSES,
} };
} };

View file

@ -21,7 +21,7 @@ export const CIRCLES = {
shortDescription: "Building your studio", shortDescription: "Building your studio",
description: "For those actively establishing or growing their coop", description: "For those actively establishing or growing their coop",
features: [ features: [
"Teams working toward applying for the Peer Accelerator", "Teams working toward applying for Cooperative Foundations",
"Early-stage coop studios", "Early-stage coop studios",
"Studios transitioning to coop model", "Studios transitioning to coop model",
], ],
@ -33,7 +33,7 @@ export const CIRCLES = {
value: "practitioner", value: "practitioner",
label: "Practitioners", label: "Practitioners",
shortDescription: "Leading and mentoring", shortDescription: "Leading and mentoring",
description: "For Peer Accelerator alumni and experienced studio founders", description: "For alumni and experienced studio founders",
features: [ features: [
"Those implementing cooperative models", "Those implementing cooperative models",
"Industry mentors and advisors", "Industry mentors and advisors",

View file

@ -0,0 +1,12 @@
export default defineNuxtRouteMiddleware(async (to, from) => {
if (process.server) return;
const { memberData, checkMemberStatus } = useAuth();
if (!memberData.value) {
const isAuthenticated = await checkMemberStatus();
if (!isAuthenticated) {
return navigateTo("/join");
}
}
});

View file

@ -6,26 +6,27 @@
<h1>About Ghost Guild</h1> <h1>About Ghost Guild</h1>
<p> <p>
A membership community for game developers exploring cooperative A membership community for game developers exploring cooperative
business models. models.
</p> </p>
</div> </div>
<div class="about-hero-right"> <div class="about-hero-right">
<div class="section-label">Our Story</div> <div class="section-label">Our Story</div>
<p> <p>
Ghost Guild grew out of Baby Ghosts, a Canadian nonprofit that's been Ghost Guild grew out of Baby Ghosts, a Canadian nonprofit that's been
supporting indie game developers since 2018. We noticed a gap: game advancing cooperative and worker-centric models in the game industry
developers interested in cooperative models had nowhere to learn, since 2023.
practice, and connect with others doing the same work.
</p> </p>
<p> <p>
Ghost Guild is the response &mdash; a membership program where Developers interested in co-op practice had few places to learn,
developers at every stage of cooperative practice can find resources, connect, and figure things out alongside others doing the same work.
events, mentorship, and community. Ghost Guild is that place: a membership community for developers at
every stage of cooperative practice, with resources, events, and peers
to learn from.
</p> </p>
<p> <p>
We don't prescribe a single model. We're a place to explore the We don't prescribe a single model. We're here to explore the options,
options, learn from people who've tried them, and build something that learn from people who've tried them, and build something that works
works for your team. for your team.
</p> </p>
</div> </div>
</div> </div>
@ -38,27 +39,16 @@
<div class="circles-grid"> <div class="circles-grid">
<div id="community" class="circle-cell"> <div id="community" class="circle-cell">
<h3 style="color: var(--c-community)">Community</h3> <h3 style="color: var(--c-community)">Community</h3>
<div class="circle-subtitle">"The open hall"</div>
<p> <p>For anyone exploring cooperative models.</p>
For anyone exploring cooperative models. Wiki access, public
events, Slack community, monthly meetings.
</p>
</div> </div>
<div id="founder" class="circle-cell"> <div id="founder" class="circle-cell">
<h3 style="color: var(--c-founder)">Founder</h3> <h3 style="color: var(--c-founder)">Founder</h3>
<div class="circle-subtitle">"The workshop"</div> <p>For people actively building cooperatives.</p>
<p>
For people actively building cooperatives. Peer accelerator,
mentorship, governance templates.
</p>
</div> </div>
<div id="practitioner" class="circle-cell"> <div id="practitioner" class="circle-cell">
<h3 style="color: var(--c-practitioner)">Practitioner</h3> <h3 style="color: var(--c-practitioner)">Practitioner</h3>
<div class="circle-subtitle">"The alcove"</div> <p>For experienced practitioners sharing what they know.</p>
<p>
For experienced practitioners. Mentoring, teaching, shaping the
program direction.
</p>
</div> </div>
</div> </div>
</div> </div>
@ -101,13 +91,12 @@
<div class="about-section"> <div class="about-section">
<div class="section-label">About Baby Ghosts</div> <div class="section-label">About Baby Ghosts</div>
<p> <p>
Ghost Guild is a program of Baby Ghosts, a Canadian nonprofit Ghost Guild is part of Baby Ghosts, a Canadian nonprofit advancing
advancing cooperative models in game development. No tracking. No ads. cooperative models in game development.
No venture capital.
</p> </p>
<p> <p>
<a href="https://babyghosts.fund" target="_blank" <a href="https://babyghosts.org" target="_blank"
>babyghosts.fund &rarr;</a >babyghosts.org &rarr;</a
> >
</p> </p>
</div> </div>

View file

@ -130,13 +130,13 @@
v-model="form.contributionTier" v-model="form.contributionTier"
class="form-select" class="form-select"
> >
<option value="0">$0/mo -- Access is a right</option> <option value="0">$0/mo -- I need support right now</option>
<option value="5">$5/mo -- A small gesture</option> <option value="5">$5/mo -- I can contribute</option>
<option value="15">$15/mo -- Sustaining (suggested)</option> <option value="15">$15/mo -- I can sustain the community (suggested)</option>
<option value="30">$30/mo -- Supporting</option> <option value="30">$30/mo -- I can support others too</option>
<option value="50">$50/mo -- Solidarity</option> <option value="50">$50/mo -- I want to sponsor multiple members</option>
</select> </select>
<p class="field-note">Every dollar above $0 goes to the Solidarity Fund. Your contribution is never a gate -- it is a gift.</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 class="form-group full-width"> <div class="form-group full-width">

View file

@ -45,6 +45,20 @@
<option v-for="col in collections" :key="col" :value="col">{{ col }}</option> <option v-for="col in collections" :key="col" :value="col">{{ col }}</option>
</select> </select>
</div> </div>
<div class="field" style="margin-bottom: 0">
<select v-model="visibilityFilter" aria-label="Filter by visibility">
<option value="">All</option>
<option value="visible">Visible</option>
<option value="hidden">Hidden</option>
</select>
</div>
<div class="field" style="margin-bottom: 0">
<select v-model="groupBy" aria-label="Group by">
<option value="collection">Group by Collection</option>
<option value="tag">Group by Tag</option>
<option value="none">No Grouping</option>
</select>
</div>
</div> </div>
<!-- Batch Action Bar --> <!-- Batch Action Bar -->
@ -74,6 +88,22 @@
{{ batchApplying ? 'Applying...' : 'Apply' }} {{ batchApplying ? 'Applying...' : 'Apply' }}
</button> </button>
</div> </div>
<div class="batch-visibility">
<button
class="btn btn-sm"
:disabled="batchVisibilityApplying"
@click="applyBatchVisibility(true)"
>
Hide
</button>
<button
class="btn btn-sm"
:disabled="batchVisibilityApplying"
@click="applyBatchVisibility(false)"
>
Show
</button>
</div>
<button class="link-btn" @click="selectedIds = []">Clear selection</button> <button class="link-btn" @click="selectedIds = []">Clear selection</button>
</div> </div>
@ -89,109 +119,390 @@
<!-- Article Table --> <!-- Article Table -->
<div v-else class="table-wrap"> <div v-else class="table-wrap">
<table v-if="filtered.length"> <!-- Grouped view -->
<thead> <template v-if="groupBy !== 'none' && visibleGroups.length">
<tr> <details
<th class="col-check"> v-for="group in visibleGroups"
<label class="custom-check" aria-label="Select all"> :key="group.name"
<input class="collection-group"
type="checkbox" >
:checked="allVisibleSelected" <summary class="collection-header">
:indeterminate="!allVisibleSelected && someVisibleSelected" <span class="collection-name">{{ group.name }}</span>
@change="toggleSelectAll" <span class="collection-count">{{ group.articles.length }}</span>
/> </summary>
<span class="check-mark" /> <table>
</label> <thead>
</th> <tr>
<th class="sortable" @click="toggleSort('collection')"> <th class="col-check">
Collection <label class="custom-check" aria-label="Select all in group">
<span v-if="sortKey === 'collection'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span> <input
</th> type="checkbox"
<th class="sortable col-title" @click="toggleSort('title')"> :checked="allInGroupSelected(group.articles)"
Title :indeterminate="!allInGroupSelected(group.articles) && someInGroupSelected(group.articles)"
<span v-if="sortKey === 'title'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span> @change="toggleSelectGroup(group.articles)"
</th> />
<th>Tags</th> <span class="check-mark" />
<th class="col-actions-head">Actions</th> </label>
</tr> </th>
</thead> <th class="col-title sortable" @click="toggleSort('title')">
<tbody> Title <span v-if="sortKey === 'title'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
<tr </th>
v-for="article in filtered" <th>Tags</th>
:key="article._id" <th class="col-vis sortable" @click="toggleSort('hidden')">
class="selectable-row" Vis <span v-if="sortKey === 'hidden'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
:class="{ 'row-selected': selectedIds.includes(article._id) }" </th>
@click="toggleSelect(article._id)" <th class="col-updated sortable" @click="toggleSort('outlineUpdatedAt')">
> Updated <span v-if="sortKey === 'outlineUpdatedAt'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
<td class="col-check" @click.stop> </th>
<label class="custom-check" :aria-label="`Select ${article.title}`"> <th class="col-actions-head">Actions</th>
<input </tr>
type="checkbox" </thead>
:checked="selectedIds.includes(article._id)" <tbody>
@change="toggleSelect(article._id)" <tr
/> v-for="article in group.articles"
<span class="check-mark" /> :key="article._id"
</label> class="selectable-row"
</td> :class="{ 'row-selected': selectedIds.includes(article._id), 'row-hidden': article.hidden }"
<td class="col-collection">{{ article.collection || '—' }}</td> @click="toggleSelect(article._id)"
<td class="col-title">
<a :href="article.url" target="_blank" rel="noopener" class="article-link">
{{ article.title }}
</a>
</td>
<td class="col-tags" @click.stop>
<div v-if="editingId !== article._id" class="tag-display">
<span v-for="tag in article.tags" :key="tag" class="tag-chip">{{ tagLabel(tag) }}</span>
<span v-if="!article.tags?.length" class="no-tags">no tags</span>
</div>
<div v-else class="tag-edit-inline">
<select
v-model="tagToAdd"
aria-label="Add tag"
class="tag-add-select"
@change="addTagToEditing"
>
<option value="">Add...</option>
<option
v-for="tag in availableTagsForEditing"
:key="tag.slug"
:value="tag.slug"
>{{ tag.label }}</option>
</select>
<div class="editing-tags">
<span
v-for="tag in editingTags"
:key="tag"
class="tag-chip tag-chip-editable"
>
{{ tagLabel(tag) }}
<button class="tag-remove" @click="removeTagFromEditing(tag)" aria-label="Remove tag">&times;</button>
</span>
</div>
<div class="tag-edit-actions">
<button class="link-btn" @click="cancelEditing">Cancel</button>
<button class="link-btn link-btn-save" :disabled="savingTags" @click="saveArticleTags">
{{ savingTags ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</td>
<td class="col-actions" @click.stop>
<button
v-if="editingId !== article._id"
class="link-btn"
@click="startEditing(article)"
> >
Edit tags <td class="col-check" @click.stop>
</button> <label class="custom-check" :aria-label="`Select ${article.title}`">
</td> <input
</tr> type="checkbox"
</tbody> :checked="selectedIds.includes(article._id)"
</table> @change="toggleSelect(article._id)"
/>
<span class="check-mark" />
</label>
</td>
<td class="col-title">
<a :href="article.url" target="_blank" rel="noopener" class="article-link" :class="{ 'article-hidden': article.hidden }">
{{ article.title }}
</a>
</td>
<td class="col-tags" @click.stop>
<div v-if="editingId !== article._id" class="tag-display">
<span v-for="tag in article.tags" :key="tag" class="tag-chip">{{ tagLabel(tag) }}</span>
<span v-if="!article.tags?.length" class="no-tags">no tags</span>
</div>
<div v-else class="tag-edit-inline">
<select
v-model="tagToAdd"
aria-label="Add tag"
class="tag-add-select"
@change="addTagToEditing"
>
<option value="">Add...</option>
<option
v-for="tag in availableTagsForEditing"
:key="tag.slug"
:value="tag.slug"
>{{ tag.label }}</option>
</select>
<div class="editing-tags">
<span
v-for="tag in editingTags"
:key="tag"
class="tag-chip tag-chip-editable"
>
{{ tagLabel(tag) }}
<button class="tag-remove" @click="removeTagFromEditing(tag)" aria-label="Remove tag">&times;</button>
</span>
</div>
<div class="tag-edit-actions">
<button class="link-btn" @click="cancelEditing">Cancel</button>
<button class="link-btn link-btn-save" :disabled="savingTags" @click="saveArticleTags">
{{ savingTags ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</td>
<td class="col-vis" @click.stop>
<button
class="vis-toggle"
:class="{ 'is-hidden': article.hidden }"
:title="article.hidden ? 'Hidden — click to show' : 'Visible — click to hide'"
@click="toggleArticleVisibility(article)"
>
{{ article.hidden ? '○' : '●' }}
</button>
</td>
<td class="col-updated">
{{ formatDate(article.outlineUpdatedAt) }}
</td>
<td class="col-actions" @click.stop>
<button
v-if="editingId !== article._id"
class="link-btn"
@click="startEditing(article)"
>
Edit tags
</button>
</td>
</tr>
</tbody>
</table>
</details>
</template>
<!-- Flat view (no grouping) -->
<template v-else-if="groupBy === 'none' && filtered.length">
<table>
<thead>
<tr>
<th class="col-check">
<label class="custom-check" aria-label="Select all">
<input
type="checkbox"
:checked="allVisibleSelected"
:indeterminate="!allVisibleSelected && someVisibleSelected"
@change="toggleSelectAll"
/>
<span class="check-mark" />
</label>
</th>
<th class="col-title sortable" @click="toggleSort('title')">
Title <span v-if="sortKey === 'title'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th class="sortable" @click="toggleSort('collection')">
Collection <span v-if="sortKey === 'collection'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th>Tags</th>
<th class="col-vis sortable" @click="toggleSort('hidden')">
Vis <span v-if="sortKey === 'hidden'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th class="col-updated sortable" @click="toggleSort('outlineUpdatedAt')">
Updated <span v-if="sortKey === 'outlineUpdatedAt'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th class="col-actions-head">Actions</th>
</tr>
</thead>
<tbody>
<tr
v-for="article in filtered"
:key="article._id"
class="selectable-row"
:class="{ 'row-selected': selectedIds.includes(article._id), 'row-hidden': article.hidden }"
@click="toggleSelect(article._id)"
>
<td class="col-check" @click.stop>
<label class="custom-check" :aria-label="`Select ${article.title}`">
<input
type="checkbox"
:checked="selectedIds.includes(article._id)"
@change="toggleSelect(article._id)"
/>
<span class="check-mark" />
</label>
</td>
<td class="col-title">
<a :href="article.url" target="_blank" rel="noopener" class="article-link" :class="{ 'article-hidden': article.hidden }">
{{ article.title }}
</a>
</td>
<td class="col-collection">{{ article.collection || '—' }}</td>
<td class="col-tags" @click.stop>
<div v-if="editingId !== article._id" class="tag-display">
<span v-for="tag in article.tags" :key="tag" class="tag-chip">{{ tagLabel(tag) }}</span>
<span v-if="!article.tags?.length" class="no-tags">no tags</span>
</div>
<div v-else class="tag-edit-inline">
<select
v-model="tagToAdd"
aria-label="Add tag"
class="tag-add-select"
@change="addTagToEditing"
>
<option value="">Add...</option>
<option
v-for="tag in availableTagsForEditing"
:key="tag.slug"
:value="tag.slug"
>{{ tag.label }}</option>
</select>
<div class="editing-tags">
<span
v-for="tag in editingTags"
:key="tag"
class="tag-chip tag-chip-editable"
>
{{ tagLabel(tag) }}
<button class="tag-remove" @click="removeTagFromEditing(tag)" aria-label="Remove tag">&times;</button>
</span>
</div>
<div class="tag-edit-actions">
<button class="link-btn" @click="cancelEditing">Cancel</button>
<button class="link-btn link-btn-save" :disabled="savingTags" @click="saveArticleTags">
{{ savingTags ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</td>
<td class="col-vis" @click.stop>
<button
class="vis-toggle"
:class="{ 'is-hidden': article.hidden }"
:title="article.hidden ? 'Hidden — click to show' : 'Visible — click to hide'"
@click="toggleArticleVisibility(article)"
>
{{ article.hidden ? '○' : '●' }}
</button>
</td>
<td class="col-updated">
{{ formatDate(article.outlineUpdatedAt) }}
</td>
<td class="col-actions" @click.stop>
<button
v-if="editingId !== article._id"
class="link-btn"
@click="startEditing(article)"
>
Edit tags
</button>
</td>
</tr>
</tbody>
</table>
</template>
<div v-else class="empty-state"> <div v-else class="empty-state">
No articles found matching your criteria No articles found matching your criteria
</div> </div>
<!-- Hidden collections drawer -->
<details v-if="groupBy !== 'none' && hiddenGroups.length" class="hidden-collections-drawer">
<summary class="hidden-collections-header">
<span>Hidden Collections</span>
<span class="collection-count">{{ hiddenGroups.length }}</span>
</summary>
<details
v-for="group in hiddenGroups"
:key="group.name"
class="collection-group hidden-collection"
>
<summary class="collection-header">
<span class="collection-name">{{ group.name }}</span>
<span class="collection-count">{{ group.articles.length }} articles</span>
<button
class="link-btn show-collection-btn"
@click.stop="showCollection(group)"
>
Show Collection
</button>
</summary>
<table>
<thead>
<tr>
<th class="col-check">
<label class="custom-check" aria-label="Select all in group">
<input
type="checkbox"
:checked="allInGroupSelected(group.articles)"
:indeterminate="!allInGroupSelected(group.articles) && someInGroupSelected(group.articles)"
@change="toggleSelectGroup(group.articles)"
/>
<span class="check-mark" />
</label>
</th>
<th class="col-title sortable" @click="toggleSort('title')">
Title <span v-if="sortKey === 'title'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th>Tags</th>
<th class="col-vis sortable" @click="toggleSort('hidden')">
Vis <span v-if="sortKey === 'hidden'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th class="col-updated sortable" @click="toggleSort('outlineUpdatedAt')">
Updated <span v-if="sortKey === 'outlineUpdatedAt'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th class="col-actions-head">Actions</th>
</tr>
</thead>
<tbody>
<tr
v-for="article in group.articles"
:key="article._id"
class="selectable-row"
:class="{ 'row-selected': selectedIds.includes(article._id), 'row-hidden': article.hidden }"
@click="toggleSelect(article._id)"
>
<td class="col-check" @click.stop>
<label class="custom-check" :aria-label="`Select ${article.title}`">
<input
type="checkbox"
:checked="selectedIds.includes(article._id)"
@change="toggleSelect(article._id)"
/>
<span class="check-mark" />
</label>
</td>
<td class="col-title">
<a :href="article.url" target="_blank" rel="noopener" class="article-link article-hidden">
{{ article.title }}
</a>
</td>
<td class="col-tags" @click.stop>
<div v-if="editingId !== article._id" class="tag-display">
<span v-for="tag in article.tags" :key="tag" class="tag-chip">{{ tagLabel(tag) }}</span>
<span v-if="!article.tags?.length" class="no-tags">no tags</span>
</div>
<div v-else class="tag-edit-inline">
<select
v-model="tagToAdd"
aria-label="Add tag"
class="tag-add-select"
@change="addTagToEditing"
>
<option value="">Add...</option>
<option
v-for="tag in availableTagsForEditing"
:key="tag.slug"
:value="tag.slug"
>{{ tag.label }}</option>
</select>
<div class="editing-tags">
<span
v-for="tag in editingTags"
:key="tag"
class="tag-chip tag-chip-editable"
>
{{ tagLabel(tag) }}
<button class="tag-remove" @click="removeTagFromEditing(tag)" aria-label="Remove tag">&times;</button>
</span>
</div>
<div class="tag-edit-actions">
<button class="link-btn" @click="cancelEditing">Cancel</button>
<button class="link-btn link-btn-save" :disabled="savingTags" @click="saveArticleTags">
{{ savingTags ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</td>
<td class="col-vis" @click.stop>
<button
class="vis-toggle is-hidden"
title="Hidden — click to show"
@click="toggleArticleVisibility(article)"
>
</button>
</td>
<td class="col-updated">
{{ formatDate(article.outlineUpdatedAt) }}
</td>
<td class="col-actions" @click.stop>
<button
v-if="editingId !== article._id"
class="link-btn"
@click="startEditing(article)"
>
Edit tags
</button>
</td>
</tr>
</tbody>
</table>
</details>
</details>
</div> </div>
</div> </div>
</template> </template>
@ -229,6 +540,8 @@ const tagLabel = (slug) => tagLabelMap.value[slug] || slug
// ---- Filters & Sort ---- // ---- Filters & Sort ----
const searchQuery = ref('') const searchQuery = ref('')
const collectionFilter = ref('') const collectionFilter = ref('')
const visibilityFilter = ref('')
const groupBy = ref('collection')
const sortKey = ref('') const sortKey = ref('')
const sortDir = ref('asc') const sortDir = ref('asc')
@ -257,7 +570,10 @@ const filtered = computed(() => {
const q = searchQuery.value.toLowerCase() const q = searchQuery.value.toLowerCase()
const matchesSearch = !q || a.title.toLowerCase().includes(q) const matchesSearch = !q || a.title.toLowerCase().includes(q)
const matchesCollection = !collectionFilter.value || a.collection === collectionFilter.value const matchesCollection = !collectionFilter.value || a.collection === collectionFilter.value
return matchesSearch && matchesCollection const matchesVisibility = !visibilityFilter.value
|| (visibilityFilter.value === 'hidden' && a.hidden)
|| (visibilityFilter.value === 'visible' && !a.hidden)
return matchesSearch && matchesCollection && matchesVisibility
}) })
if (sortKey.value) { if (sortKey.value) {
@ -273,6 +589,58 @@ const filtered = computed(() => {
return result return result
}) })
const groupedFiltered = computed(() => {
if (groupBy.value === 'none') return []
const groups = new Map()
if (groupBy.value === 'tag') {
for (const article of filtered.value) {
if (!article.tags?.length) {
const key = 'Untagged'
if (!groups.has(key)) groups.set(key, [])
groups.get(key).push(article)
} else {
for (const tag of article.tags) {
const key = tagLabel(tag)
if (!groups.has(key)) groups.set(key, [])
groups.get(key).push(article)
}
}
}
} else {
for (const article of filtered.value) {
const key = article.collection || 'Uncategorized'
if (!groups.has(key)) groups.set(key, [])
groups.get(key).push(article)
}
}
return [...groups.entries()]
.map(([name, articles]) => ({ name, articles }))
.sort((a, b) => a.name.localeCompare(b.name))
})
const visibleGroups = computed(() => {
if (visibilityFilter.value === 'hidden') return groupedFiltered.value
return groupedFiltered.value.filter((group) =>
group.articles.some((a) => !a.hidden),
)
})
const hiddenGroups = computed(() => {
if (visibilityFilter.value) return []
return groupedFiltered.value.filter((group) =>
group.articles.every((a) => a.hidden),
)
})
const formatDate = (dateStr) => {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
// ---- Selection ---- // ---- Selection ----
const selectedIds = ref([]) const selectedIds = ref([])
@ -313,6 +681,28 @@ const toggleSelect = (id) => {
} }
} }
const allInGroupSelected = (groupArticles) => {
if (!groupArticles.length) return false
return groupArticles.every((a) => selectedIds.value.includes(a._id))
}
const someInGroupSelected = (groupArticles) => {
return groupArticles.some((a) => selectedIds.value.includes(a._id))
}
const toggleSelectGroup = (groupArticles) => {
if (allInGroupSelected(groupArticles)) {
const groupIds = new Set(groupArticles.map((a) => a._id))
selectedIds.value = selectedIds.value.filter((id) => !groupIds.has(id))
} else {
const currentSet = new Set(selectedIds.value)
for (const a of groupArticles) {
currentSet.add(a._id)
}
selectedIds.value = [...currentSet]
}
}
const selectAllInCollection = () => { const selectAllInCollection = () => {
if (!collectionFilter.value || !articles.value) return if (!collectionFilter.value || !articles.value) return
const currentSet = new Set(selectedIds.value) const currentSet = new Set(selectedIds.value)
@ -442,6 +832,73 @@ const applyBatchTag = async () => {
batchApplying.value = false batchApplying.value = false
} }
} }
// ---- Visibility Toggle ----
const toggleArticleVisibility = async (article) => {
try {
await $fetch(`/api/admin/wiki/${article._id}`, {
method: 'PATCH',
body: { hidden: !article.hidden },
})
await refresh()
} catch (err) {
toast.add({
title: 'Failed to update visibility',
description: err.data?.statusMessage || err.message,
color: 'red',
})
}
}
// ---- Show Entire Collection ----
const showCollection = async (group) => {
const ids = group.articles.map((a) => a._id)
try {
await $fetch('/api/admin/wiki/batch-visibility', {
method: 'POST',
body: { articleIds: ids, hidden: false },
})
await refresh()
toast.add({
title: `${group.name} is now visible`,
color: 'green',
})
} catch (err) {
toast.add({
title: 'Failed to show collection',
description: err.data?.statusMessage || err.message,
color: 'red',
})
}
}
// ---- Batch Visibility ----
const batchVisibilityApplying = ref(false)
const applyBatchVisibility = async (hidden) => {
if (!selectedIds.value.length) return
batchVisibilityApplying.value = true
try {
const result = await $fetch('/api/admin/wiki/batch-visibility', {
method: 'POST',
body: { articleIds: selectedIds.value, hidden },
})
await refresh()
toast.add({
title: `${result.modified} articles ${hidden ? 'hidden' : 'shown'}`,
color: 'green',
})
selectedIds.value = []
} catch (err) {
toast.add({
title: 'Batch visibility failed',
description: err.data?.statusMessage || err.message,
color: 'red',
})
} finally {
batchVisibilityApplying.value = false
}
}
</script> </script>
<style scoped> <style scoped>
@ -566,9 +1023,109 @@ const applyBatchTag = async () => {
min-width: 80px; min-width: 80px;
} }
/* ---- COLLECTION GROUPS ---- */
.collection-group {
border-bottom: 1px dashed var(--border);
}
.collection-group:last-child {
border-bottom: none;
}
.collection-header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 28px;
cursor: pointer;
list-style: none;
user-select: none;
border-bottom: 1px dashed var(--border);
transition: background 0.1s;
}
.collection-header:hover {
background: var(--surface);
}
.collection-header::-webkit-details-marker {
display: none;
}
.collection-header::before {
content: "▸";
font-size: 11px;
color: var(--text-faint);
transition: transform 0.15s;
}
.collection-group[open] > .collection-header::before {
content: "▾";
}
.collection-name {
font-size: 13px;
font-weight: 500;
color: var(--text-bright);
}
.collection-count {
font-size: 11px;
color: var(--text-faint);
}
.col-updated {
font-size: 11px;
color: var(--text-dim);
white-space: nowrap;
width: 100px;
}
/* ---- HIDDEN COLLECTIONS DRAWER ---- */
.hidden-collections-drawer {
margin-top: 24px;
border-top: 1px dashed var(--border);
}
.hidden-collections-header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 28px;
cursor: pointer;
list-style: none;
user-select: none;
font-size: 12px;
color: var(--text-faint);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.hidden-collections-header::-webkit-details-marker {
display: none;
}
.hidden-collections-header::before {
content: "▸";
font-size: 11px;
color: var(--text-faint);
}
.hidden-collections-drawer[open] > .hidden-collections-header::before {
content: "▾";
}
.hidden-collection .collection-header {
padding-left: 44px;
}
.show-collection-btn {
margin-left: auto;
}
/* ---- TABLE ---- */ /* ---- TABLE ---- */
.table-wrap { .table-wrap {
padding: 0 28px 24px; padding: 0 0 24px;
} }
table { table {
@ -690,6 +1247,49 @@ tbody td {
border-bottom: 1.5px solid var(--bg); border-bottom: 1.5px solid var(--bg);
} }
/* ---- VISIBILITY ---- */
.col-vis {
width: 50px;
text-align: center;
}
.vis-toggle {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
color: var(--candle);
padding: 2px 6px;
line-height: 1;
}
.vis-toggle.is-hidden {
color: var(--text-faint);
}
.vis-toggle:hover {
opacity: 0.7;
}
.row-hidden {
opacity: 0.5;
}
.article-hidden {
text-decoration: line-through;
color: var(--text-faint);
}
.batch-visibility {
display: flex;
gap: 6px;
}
.btn-sm {
font-size: 11px;
padding: 3px 10px;
}
/* ---- COLUMNS ---- */ /* ---- COLUMNS ---- */
.col-collection { .col-collection {
font-size: 11px; font-size: 11px;

View file

@ -14,7 +14,7 @@ useHead({ title: "Signed Out — Ghost Guild" });
<hr class="section-divider" /> <hr class="section-divider" />
<p class="auth-body" role="status"> <p class="auth-body" role="status">
You have been successfully signed out of your session. You've been signed out.
</p> </p>
<a href="https://wiki.ghostguild.org" class="btn btn-primary auth-btn"> <a href="https://wiki.ghostguild.org" class="btn btn-primary auth-btn">

View file

@ -57,7 +57,7 @@
</div> </div>
<div class="coming-soon-preregister"> <div class="coming-soon-preregister">
<a href="https://babyghosts.fund/ghost-guild/">Pre-Register</a> <a href="https://babyghosts.org/ghost-guild/">Pre-Register</a>
</div> </div>
</UForm> </UForm>
</div> </div>

View file

@ -216,6 +216,7 @@
<!-- Not Logged In --> <!-- Not Logged In -->
<div v-else class="dashed-box"> <div v-else class="dashed-box">
<div class="box-title">Registration</div> <div class="box-title">Registration</div>
<p v-if="!event.membersOnly" class="reg-open">Open to everyone no membership required</p>
<form @submit.prevent="handleRegistration"> <form @submit.prevent="handleRegistration">
<div class="field"> <div class="field">
<label>Name</label> <label>Name</label>
@ -689,6 +690,11 @@ useHead(() => ({
color: var(--text-faint); color: var(--text-faint);
margin-bottom: 10px; margin-bottom: 10px;
} }
.reg-open {
font-size: 12px;
color: var(--text-dim);
margin-bottom: 10px;
}
.cal-link { .cal-link {
display: block; display: block;
margin-top: 8px; margin-top: 8px;

View file

@ -5,7 +5,7 @@
<h1>Events</h1> <h1>Events</h1>
<p> <p>
Workshops, meetups, and gatherings for game developers practicing Workshops, meetups, and gatherings for game developers practicing
cooperative models. cooperative models. Some events are open to the public.
</p> </p>
</div> </div>
@ -59,7 +59,7 @@
<div class="event-badges"> <div class="event-badges">
<span v-if="event.membersOnly" class="members-badge">Members</span> <span v-if="event.membersOnly" class="members-badge">Members</span>
<CircleBadge v-if="event.circle" :circle="event.circle" /> <CircleBadge v-if="event.circle" :circle="event.circle" />
<span v-else class="badge all">All</span> <span v-else class="badge all">Public</span>
</div> </div>
</div> </div>
<div v-if="!filteredEvents?.length" class="empty">No events found</div> <div v-if="!filteredEvents?.length" class="empty">No events found</div>
@ -93,31 +93,6 @@
</div> </div>
</div> </div>
<!-- PROPOSE AN EVENT -->
<!-- TODO: Build /events/propose page + form for members to submit event ideas.
Think through before building:
- Who can propose? Members only, or any circle?
- Required fields: title, description, proposed date/time, target circle,
format (workshop/social/talk/etc.), estimated attendance
- Approval workflow: does an admin review and publish, or does it auto-post
as a draft?
- Interest threshold mechanic: can other members +1 a proposal to signal
demand before it gets formally scheduled?
- Notifications: proposer gets notified when approved/declined
See CLAUDE.md product spec for additional context. -->
<div class="full-section">
<div class="section-label">Have an idea?</div>
<DashedBox>
<h2>Propose an Event</h2>
<p>
Members can propose events for any circle. Workshops, social hangs,
talks, or anything else that serves the community.
</p>
<span class="cta cta-soon"
>Propose an event &rarr; <em>coming soon</em></span
>
</DashedBox>
</div>
</div> </div>
</template> </template>
@ -382,34 +357,6 @@ const isAlmostFull = (event) => {
align-items: center; align-items: center;
} }
/* ---- PROPOSE ---- */
.full-section h2 {
font-family: var(--font-display);
font-size: 16px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 4px;
}
.full-section p {
font-size: 12px;
color: var(--text-dim);
line-height: 1.7;
max-width: 560px;
}
.cta {
display: inline-block;
margin-top: 8px;
font-size: 12px;
color: var(--candle);
}
.cta-soon {
color: var(--text-dim);
cursor: default;
}
.cta-soon em {
font-style: normal;
font-size: 10px;
}
.filter-toggle { .filter-toggle {
display: flex; display: flex;

View file

@ -2,19 +2,19 @@
<div> <div>
<!-- HERO --> <!-- HERO -->
<div class="hero"> <div class="hero">
<h1> <h1>Ghost Guild is where game developers explore cooperative models.</h1>
Ghost Guild is where game developers explore cooperative models.
</h1>
<p> <p>
Resources, events, and a community of people figuring it out. Three Resources, events, and a community of people figuring it out. Three
circles, no hierarchy. $050/mo, pay what you can. circles, pay what you can.
</p> </p>
<div class="hero-links"> <div class="hero-links">
<NuxtLink to="/join" class="hero-link primary" <NuxtLink to="/join" class="hero-link primary"
>Become a member</NuxtLink >Become a member</NuxtLink
> >
<NuxtLink to="/wiki" class="hero-link">Read the wiki</NuxtLink> <a href="https://wiki.ghostguild.org" class="hero-link"
<NuxtLink to="/about" class="hero-link">What is this?</NuxtLink> >Read the wiki</a
>
<NuxtLink to="/about" class="hero-link">About the Guild</NuxtLink>
</div> </div>
</div> </div>
@ -30,10 +30,6 @@
</div> </div>
<h2>{{ circle.metaphor }}</h2> <h2>{{ circle.metaphor }}</h2>
<p>{{ circle.blurb }}</p> <p>{{ circle.blurb }}</p>
<details>
<summary>What's included?</summary>
<p>{{ circle.included }}</p>
</details>
</div> </div>
</div> </div>
@ -64,28 +60,22 @@
<div class="block-inset"> <div class="block-inset">
<div class="label">Recently in the Wiki</div> <div class="label">Recently in the Wiki</div>
</div> </div>
<div class="wiki-list"> <div v-if="wikiArticles?.length" class="wiki-list">
<div class="wiki-item"> <div
v-for="article in wikiArticles"
:key="article._id"
class="wiki-item"
>
<div class="block-inset wiki-item-inner"> <div class="block-inset wiki-item-inner">
<a href="/wiki">Revenue sharing models</a> <a :href="article.url" target="_blank">{{ article.title }}</a>
</div>
</div>
<div class="wiki-item">
<div class="block-inset wiki-item-inner">
<a href="/wiki">What is a cooperative studio?</a>
</div>
</div>
<div class="wiki-item">
<div class="block-inset wiki-item-inner">
<a href="/wiki">Governance structures</a>
</div>
</div>
<div class="wiki-item">
<div class="block-inset wiki-item-inner">
<a href="/wiki">Legal incorporation guide</a>
</div> </div>
</div> </div>
</div> </div>
<div v-else class="block-inset">
<p class="empty">
<a href="https://wiki.ghostguild.org">Browse the wiki &rarr;</a>
</p>
</div>
</div> </div>
</div> </div>
@ -106,9 +96,12 @@
<p> <p>
The games industry is full of stories about crunch, layoffs, and studios The games industry is full of stories about crunch, layoffs, and studios
that extract value from workers. Cooperatives are one alternative not that extract value from workers. Cooperatives are one alternative not
the only one, but one worth <a href="/wiki">practicing together</a>. the only one, but one worth
<a href="https://wiki.ghostguild.org">practicing together</a>.
</p>
<p>
<a href="https://wiki.ghostguild.org">Read more in the wiki &rarr;</a>
</p> </p>
<p><a href="/wiki">Read more in the wiki &rarr;</a></p>
</ParchmentInset> </ParchmentInset>
</div> </div>
</template> </template>
@ -123,33 +116,32 @@ const { data: events } = await useFetch("/api/events", {
default: () => [], default: () => [],
}); });
const { data: wikiArticles } = await useFetch("/api/wiki/recent", {
query: { limit: 4 },
default: () => [],
});
const circleData = [ const circleData = [
{ {
value: "community", value: "community",
label: "Community", label: "Community",
metaphor: "The open hall", metaphor: "The open hall",
blurb: blurb:
"Arrival, curiosity, orientation. For anyone exploring cooperative models in game development. Access the wiki, public events, and Slack.", "For anyone exploring cooperative models in game development. Solo devs, researchers, students, people who just heard about this and want to know more.",
included:
"Wiki access, public events, Slack community, monthly guild meetings. Free or pay-what-you-can.",
}, },
{ {
value: "founder", value: "founder",
label: "Founder", label: "Founder",
metaphor: "The workshop", metaphor: "The workshop",
blurb: blurb:
"For people actively building cooperatives. Structured practice, peer support, templates, and hands-on resources.", "For people actively building cooperative studios. You're working through governance, legal structure, revenue sharing, and all the hard parts.",
included:
"Everything in Community plus the peer accelerator, 1:1 mentorship matching, and Founder-only workshops.",
}, },
{ {
value: "practitioner", value: "practitioner",
label: "Practitioner", label: "Practitioner",
metaphor: "The alcove", metaphor: "The alcove",
blurb: blurb:
"Where experience is shared and knowledge given back. Teaching, advising, shaping the program itself.", "Where experience is shared and knowledge given back. You're here to teach, advise, mentor, and help shape the program itself. Alumni welcome.",
included:
"Everything in Founder plus the ability to mentor, propose events, contribute to the wiki, and help govern the Guild.",
}, },
]; ];
@ -257,26 +249,6 @@ const formatDate = (dateStr) => {
margin-bottom: 8px; margin-bottom: 8px;
} }
/* ---- DETAILS ---- */
details {
margin-top: 12px;
}
details summary {
font-size: 12px;
color: var(--candle-dim);
cursor: pointer;
list-style: none;
}
details summary::before {
content: "+ ";
}
details[open] summary::before {
content: " ";
}
details p {
margin-top: 8px;
}
/* ---- EVENT LIST ---- */ /* ---- EVENT LIST ---- */
.event-item { .event-item {
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);

View file

@ -3,7 +3,10 @@
<!-- HERO --> <!-- HERO -->
<div class="hero"> <div class="hero">
<h1>Join Ghost Guild</h1> <h1>Join Ghost Guild</h1>
<p>Resources, events, and a community of people figuring it out. Everyone gets everything. Pay what you can.</p> <p>
Resources, events, and a community of people figuring it out. Everyone
gets everything. Pay what you can.
</p>
</div> </div>
<!-- Already a member --> <!-- Already a member -->
@ -11,31 +14,46 @@
<div class="full-section"> <div class="full-section">
<h2>You're already a member</h2> <h2>You're already a member</h2>
<p class="section-intro"> <p class="section-intro">
Welcome back, {{ memberData?.name || 'member' }}. You're part of Ghost Guild in the Welcome back, {{ memberData?.name || "member" }}. You're part of Ghost
<span class="capitalize">{{ memberData?.circle || 'community' }}</span> circle. Guild in the
<span class="capitalize">{{
memberData?.circle || "community"
}}</span>
circle.
</p> </p>
<div class="member-info-grid"> <div class="member-info-grid">
<DashedBox :hoverable="false"> <DashedBox :hoverable="false">
<div class="section-label">Circle</div> <div class="section-label">Circle</div>
<div class="info-value capitalize">{{ memberData?.circle || 'Community' }}</div> <div class="info-value capitalize">
{{ memberData?.circle || "Community" }}
</div>
</DashedBox> </DashedBox>
<DashedBox :hoverable="false"> <DashedBox :hoverable="false">
<div class="section-label">Contribution</div> <div class="section-label">Contribution</div>
<div class="info-value">${{ memberData?.contributionTier || '0' }} CAD/month</div> <div class="info-value">
${{ memberData?.contributionTier || "0" }} CAD/month
</div>
</DashedBox> </DashedBox>
</div> </div>
<div class="button-row"> <div class="button-row">
<NuxtLink to="/member/dashboard" class="form-submit">Go to Dashboard</NuxtLink> <NuxtLink to="/member/dashboard" class="form-submit"
>Go to Dashboard</NuxtLink
>
<NuxtLink to="/member/profile" class="btn">Edit Profile</NuxtLink> <NuxtLink to="/member/profile" class="btn">Edit Profile</NuxtLink>
</div> </div>
</div> </div>
<ParchmentInset> <ParchmentInset>
<h2>Want to change your circle or contribution?</h2> <h2>Want to change your circle or contribution?</h2>
<p>You can update your circle and adjust your monthly contribution at any time from your profile settings.</p> <p>
<NuxtLink to="/member/profile" class="parchment-link">Update Membership Settings</NuxtLink> You can update your circle and adjust your monthly contribution at any
time from your profile settings.
</p>
<NuxtLink to="/member/profile" class="parchment-link"
>Update Membership Settings</NuxtLink
>
</ParchmentInset> </ParchmentInset>
</template> </template>
@ -45,183 +63,237 @@
<ParchmentInset> <ParchmentInset>
<h2>How membership works</h2> <h2>How membership works</h2>
<ul> <ul>
<li>Full access to the knowledge commons, events, Slack community, and peer support</li> <li>
Full access to the knowledge commons, events, Slack community, and
peer support
</li>
<li>One member, one vote in all decisions</li> <li>One member, one vote in all decisions</li>
<li>Your circle is where you are in your journey, not rank</li> <li>Your circle is where you are in your journey, not rank</li>
<li>Your contribution is what you can afford ($0--50+/month, separate from your circle)</li> <li>
<li>Higher contributions create solidarity spots for those who need them</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>
<!-- THREE CIRCLES --> <!-- THREE CIRCLES -->
<div class="content-row"> <div class="content-row">
<div class="content-block"> <div class="content-block">
<div class="section-label" style="color: var(--c-community);">Community</div> <div class="section-label" style="color: var(--c-community)">
Community
</div>
<h2>Exploring</h2> <h2>Exploring</h2>
<p>For game workers curious about cooperatives and people exploring alternative work models. You might be a solo developer, a student, a researcher, or just someone who heard about this and wants to know more. Start here.</p> <p>
<p class="circle-not-sure">Not sure where you fit? Start with Community. You can always move later.</p> For game workers curious about cooperatives and people exploring
alternative work models. You might be a solo developer, a student, a
researcher, or just someone who heard about this and wants to know
more. Start here.
</p>
</div> </div>
<div class="content-block"> <div class="content-block">
<div class="section-label" style="color: var(--c-founder);">Founder</div> <div class="section-label" style="color: var(--c-founder)">
Founder
</div>
<h2>Building</h2> <h2>Building</h2>
<p>For people actively building cooperative studios. You have a team, or you are forming one. You are working through governance, legal structure, revenue sharing, and all the hard parts. You want structured support and peers doing the same thing.</p> <p>
For people actively building cooperative studios. You have a team,
or you are forming one. You are working through governance, legal
structure, revenue sharing, and all the hard parts. You want
structured support and peers doing the same thing.
</p>
</div> </div>
<div class="content-block"> <div class="content-block">
<div class="section-label" style="color: var(--c-practitioner);">Practitioner</div> <div class="section-label" style="color: var(--c-practitioner)">
Practitioner
</div>
<h2>Practicing</h2> <h2>Practicing</h2>
<p>For those already running cooperative studios or with deep experience in cooperative business. You are here to teach, advise, mentor, and help shape the program itself. Peer Accelerator alumni land here.</p> <p>
For those already running cooperative studios or with deep
experience in cooperative practice. You are here to teach, advise,
mentor, and help shape the program itself. Alumni.
</p>
</div> </div>
</div> </div>
<!-- CONTRIBUTION TIERS --> <!-- CONTRIBUTION + SIGN UP (two columns) -->
<div class="full-section"> <div v-if="currentStep === 1" class="join-two-col">
<div class="section-label" style="margin-bottom: 12px;">Monthly Contribution</div> <!-- Left: Monthly Contribution -->
<h2>Pay what you can</h2> <div class="join-col">
<div class="tier-row"> <div class="section-label" style="margin-bottom: 12px">
<div class="tier-card"> Monthly Contribution
<div class="tier-amount">$0</div>
<div class="tier-freq">/month</div>
<div class="tier-desc">Access is a right</div>
</div> </div>
<div class="tier-card"> <h2>Pay what you can</h2>
<div class="tier-amount">$5</div> <ul class="tier-list">
<div class="tier-freq">/month</div> <li><span class="tier-amt">$0</span> I need support right now</li>
<div class="tier-desc">A small gesture</div> <li><span class="tier-amt">$5</span> I can contribute</li>
</div> <li>
<div class="tier-card suggested"> <span class="tier-amt">$15</span> I can sustain the community
<div class="tier-amount">$15</div> (suggested)
<div class="tier-freq">/month</div> </li>
<div class="tier-desc">Sustaining</div> <li><span class="tier-amt">$30</span> I can support others too</li>
<div class="tier-badge">suggested</div> <li>
</div> <span class="tier-amt">$50</span> I want to sponsor multiple
<div class="tier-card"> members
<div class="tier-amount">$30</div> </li>
<div class="tier-freq">/month</div> </ul>
<div class="tier-desc">Supporting</div> <p class="solidarity-note">
</div> Pay what you can. If you can pay more, you're making room for
<div class="tier-card"> someone who can't.
<div class="tier-amount">$50</div> </p>
<div class="tier-freq">/month</div> <p class="circle-not-sure">
<div class="tier-desc">Solidarity</div> Not sure where you fit? Start with Community. You can always move
</div> later.
</div> </p>
<p class="solidarity-note">Every dollar above $0 goes to the Solidarity Fund, which covers membership for people who need it. Higher tiers directly sponsor other members. Your contribution is never a gate -- it is a gift.</p>
</div>
<!-- SIGN UP FORM -->
<div v-if="currentStep === 1" class="form-section">
<h2>Become a member</h2>
<p class="form-intro">We will send you a magic link to confirm your email. No passwords, no fuss.</p>
<!-- Error Message -->
<div v-if="errorMessage" class="error-box">
{{ errorMessage }}
</div> </div>
<form @submit.prevent="handleSubmit"> <!-- Right: Become a member -->
<div class="form-grid"> <div class="join-col">
<div class="form-group"> <h2>Become a member</h2>
<label class="form-label" for="join-name">Full Name</label> <p class="form-intro">
<input You'll get a magic link to confirm your email. No passwords.
id="join-name" </p>
v-model="form.name"
class="form-input" <!-- Error Message -->
type="text" <div v-if="errorMessage" class="error-box">
placeholder="Your name" {{ errorMessage }}
required </div>
/>
</div> <form @submit.prevent="handleSubmit">
<div class="form-group"> <div class="form-stack">
<label class="form-label" for="join-email">Email Address</label> <div class="form-group">
<input <label class="form-label" for="join-name">Full Name</label>
id="join-email" <input
v-model="form.email" id="join-name"
class="form-input" v-model="form.name"
type="email" class="form-input"
placeholder="you@example.com" type="text"
required placeholder="Your name"
/> required
</div> />
<div class="form-group full-width"> </div>
<label class="form-label">Circle</label> <div class="form-group">
<div class="circle-radios"> <label class="form-label" for="join-email">Email Address</label>
<div class="circle-radio community"> <input
<input id="join-email"
id="circle-community" v-model="form.email"
v-model="form.circle" class="form-input"
type="radio" type="email"
name="circle" placeholder="you@example.com"
value="community" required
/> />
<label for="circle-community"> </div>
<span class="circle-label-name" style="color: var(--c-community);">Community</span> <div class="form-group">
<span class="circle-label-desc">Exploring</span> <label class="form-label">Circle</label>
</label> <div class="circle-radios">
</div> <div class="circle-radio community">
<div class="circle-radio founder"> <input
<input id="circle-community"
id="circle-founder" v-model="form.circle"
v-model="form.circle" type="radio"
type="radio" name="circle"
name="circle" value="community"
value="founder" />
/> <label for="circle-community">
<label for="circle-founder"> <span
<span class="circle-label-name" style="color: var(--c-founder);">Founder</span> class="circle-label-name"
<span class="circle-label-desc">Building</span> style="color: var(--c-community)"
</label> >Community</span
</div> >
<div class="circle-radio practitioner"> <span class="circle-label-desc">Exploring</span>
<input </label>
id="circle-practitioner" </div>
v-model="form.circle" <div class="circle-radio founder">
type="radio" <input
name="circle" id="circle-founder"
value="practitioner" v-model="form.circle"
/> type="radio"
<label for="circle-practitioner"> name="circle"
<span class="circle-label-name" style="color: var(--c-practitioner);">Practitioner</span> value="founder"
<span class="circle-label-desc">Practicing</span> />
</label> <label for="circle-founder">
<span
class="circle-label-name"
style="color: var(--c-founder)"
>Founder</span
>
<span class="circle-label-desc">Building</span>
</label>
</div>
<div class="circle-radio practitioner">
<input
id="circle-practitioner"
v-model="form.circle"
type="radio"
name="circle"
value="practitioner"
/>
<label for="circle-practitioner">
<span
class="circle-label-name"
style="color: var(--c-practitioner)"
>Practitioner</span
>
<span class="circle-label-desc">Practicing</span>
</label>
</div>
</div> </div>
</div> </div>
<div class="form-group">
<label class="form-label" for="join-contribution"
>Monthly Contribution</label
>
<select
id="join-contribution"
v-model="form.contributionTier"
class="form-select"
>
<option value="0">$0/mo -- I need support right now</option>
<option value="5">$5/mo -- I can contribute</option>
<option value="15">
$15/mo -- I can sustain the community (suggested)
</option>
<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 class="form-group">
<button
class="form-submit"
type="submit"
:disabled="!isFormValid || isSubmitting"
>
<span v-if="isSubmitting">Processing...</span>
<span v-else-if="needsPayment">Continue to Payment</span>
<span v-else>Become a Member</span>
</button>
</div>
</div> </div>
<div class="form-group full-width"> <p class="form-note">
<label class="form-label" for="join-contribution">Monthly Contribution</label> By joining you agree to our
<select <NuxtLink to="/guidelines">community guidelines</NuxtLink>. You
id="join-contribution" can change your circle or contribution at any time from your
v-model="form.contributionTier" dashboard. Payment is handled securely through
class="form-select" <a href="https://www.helcim.com" target="_blank" rel="noopener"
> >Helcim</a
<option value="0">$0/mo -- Access is a right</option> >.
<option value="5">$5/mo -- A small gesture</option> </p>
<option value="15">$15/mo -- Sustaining (suggested)</option> </form>
<option value="30">$30/mo -- Supporting</option> </div>
<option value="50">$50/mo -- Solidarity</option>
</select>
</div>
<div class="form-group">
<button
class="form-submit"
type="submit"
:disabled="!isFormValid || isSubmitting"
>
<span v-if="isSubmitting">Processing...</span>
<span v-else-if="needsPayment">Continue to Payment</span>
<span v-else>Become a Member</span>
</button>
</div>
</div>
<p class="form-note">By joining you agree to our <NuxtLink to="/guidelines">community guidelines</NuxtLink>. You can change your circle or contribution at any time from your dashboard. Payment is handled securely through <a href="https://www.helcim.com" target="_blank" rel="noopener">Helcim</a>.</p>
</form>
</div> </div>
<!-- Step 2: Payment --> <!-- Step 2: Payment -->
<div v-if="currentStep === 2" class="form-section"> <div v-if="currentStep === 2" class="form-section">
<h2>Payment Information</h2> <h2>Payment Information</h2>
<p class="form-intro"> <p class="form-intro">
You're signing up for the {{ selectedTier.label }} plan -- You're signing up for the {{ selectedTier.label }} plan -- ${{
${{ selectedTier.amount }} CAD / month selectedTier.amount
}}
CAD / month
</p> </p>
<!-- Error Message --> <!-- Error Message -->
@ -230,15 +302,14 @@
</div> </div>
<DashedBox :hoverable="false"> <DashedBox :hoverable="false">
<p class="payment-instruction">Click "Complete Payment" below to open the secure payment modal and verify your payment method.</p> <p class="payment-instruction">
Click "Complete Payment" below to open the secure payment modal and
verify your payment method.
</p>
</DashedBox> </DashedBox>
<div class="button-row" style="margin-top: 24px;"> <div class="button-row" style="margin-top: 24px">
<button <button class="btn" :disabled="isSubmitting" @click="goBack">
class="btn"
:disabled="isSubmitting"
@click="goBack"
>
Back Back
</button> </button>
<button <button
@ -261,7 +332,9 @@
</div> </div>
<DashedBox :hoverable="false"> <DashedBox :hoverable="false">
<div class="section-label" style="margin-bottom: 12px;">Membership Details</div> <div class="section-label" style="margin-bottom: 12px">
Membership Details
</div>
<dl class="details-list"> <dl class="details-list">
<div class="details-row"> <div class="details-row">
<dt>Name</dt> <dt>Name</dt>
@ -286,17 +359,25 @@
</dl> </dl>
</DashedBox> </DashedBox>
<p class="form-note" style="margin-top: 20px;"> <p class="form-note" style="margin-top: 20px">
We've sent a confirmation email to {{ form.email }} with your membership details. We've sent a confirmation email to {{ form.email }} with your
membership details.
</p> </p>
<DashedBox :hoverable="false" style="margin-top: 16px;"> <DashedBox :hoverable="false" style="margin-top: 16px">
<p class="redirect-note">You will be automatically redirected to your dashboard in a few seconds...</p> <p class="redirect-note">
You will be automatically redirected to your dashboard in a few
seconds...
</p>
</DashedBox> </DashedBox>
<div class="button-row" style="margin-top: 24px;"> <div class="button-row" style="margin-top: 24px">
<NuxtLink to="/member/dashboard" class="form-submit">Go to Dashboard Now</NuxtLink> <NuxtLink to="/member/dashboard" class="form-submit"
<button class="btn" @click="resetForm">Register Another Member</button> >Go to Dashboard Now</NuxtLink
>
<button class="btn" @click="resetForm">
Register Another Member
</button>
</div> </div>
</div> </div>
</template> </template>
@ -492,7 +573,7 @@ const createSubscription = async (cardToken = null) => {
if (response.success) { if (response.success) {
subscriptionData.value = response.subscription; subscriptionData.value = response.subscription;
currentStep.value = 3; currentStep.value = 3;
successMessage.value = "Your membership has been activated successfully!"; successMessage.value = "Your membership is active.";
// Check member status to ensure user is properly authenticated // Check member status to ensure user is properly authenticated
await checkMemberStatus(); await checkMemberStatus();
@ -560,7 +641,7 @@ onUnmounted(() => {
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);
} }
.hero h1 { .hero h1 {
font-family: 'Brygada 1918', serif; font-family: "Brygada 1918", serif;
font-size: 36px; font-size: 36px;
font-weight: 600; font-weight: 600;
color: var(--text-bright); color: var(--text-bright);
@ -623,7 +704,7 @@ onUnmounted(() => {
border-right: none; border-right: none;
} }
.content-block h2 { .content-block h2 {
font-family: 'Brygada 1918', serif; font-family: "Brygada 1918", serif;
font-size: 18px; font-size: 18px;
font-weight: 500; font-weight: 500;
color: var(--text-bright); color: var(--text-bright);
@ -641,13 +722,33 @@ onUnmounted(() => {
line-height: 1.6; line-height: 1.6;
} }
/* ---- TWO-COLUMN JOIN LAYOUT ---- */
.join-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
border-bottom: 1px dashed var(--border);
}
.join-col {
padding: 32px;
}
.join-col:first-child {
border-right: 1px dashed var(--border);
}
.join-col h2 {
font-family: "Brygada 1918", serif;
font-size: 20px;
font-weight: 500;
color: var(--text-bright);
margin-bottom: 16px;
}
/* ---- FULL-WIDTH SECTION ---- */ /* ---- FULL-WIDTH SECTION ---- */
.full-section { .full-section {
padding: 32px; padding: 32px;
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);
} }
.full-section h2 { .full-section h2 {
font-family: 'Brygada 1918', serif; font-family: "Brygada 1918", serif;
font-size: 20px; font-size: 20px;
font-weight: 500; font-weight: 500;
color: var(--text-bright); color: var(--text-bright);
@ -660,65 +761,32 @@ onUnmounted(() => {
margin-bottom: 20px; margin-bottom: 20px;
} }
/* ---- TIER CARDS ---- */ /* ---- TIER LIST (matches about page) ---- */
.tier-row { .tier-list {
display: grid; list-style: none;
grid-template-columns: repeat(5, 1fr); padding: 0;
}
.tier-list li {
padding: 5px 0;
font-size: 12px;
color: var(--text-dim);
border-bottom: 1px dashed var(--border);
display: flex;
gap: 12px; gap: 12px;
margin: 16px 0;
} }
.tier-card { .tier-list li:last-child {
border: 1px dashed var(--border); border-bottom: none;
padding: 20px 16px;
text-align: center;
transition: border-color 0.2s;
} }
.tier-card:hover { .tier-amt {
border-color: var(--candle-faint);
}
.tier-card.suggested {
border-color: var(--candle-dim);
border-style: solid;
}
.tier-card.suggested:hover {
border-color: var(--candle);
}
.tier-amount {
font-family: 'Brygada 1918', serif;
font-size: 22px;
font-weight: 600;
color: var(--text-bright); color: var(--text-bright);
margin-bottom: 2px; font-weight: 600;
} min-width: 36px;
.tier-card.suggested .tier-amount {
color: var(--candle);
}
.tier-freq {
font-size: 11px;
color: var(--text-faint);
margin-bottom: 10px;
}
.tier-desc {
font-size: 11px;
color: var(--text-faint);
line-height: 1.5;
}
.tier-badge {
display: inline-block;
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--candle-dim);
border: 1px dashed var(--candle-faint);
padding: 1px 6px;
margin-top: 8px;
} }
.solidarity-note { .solidarity-note {
font-size: 12px; font-size: 12px;
color: var(--text-dim); color: var(--text-dim);
line-height: 1.65; line-height: 1.65;
margin-top: 16px; margin-top: 16px;
max-width: 560px;
} }
/* ---- FORM SECTION ---- */ /* ---- FORM SECTION ---- */
@ -727,7 +795,7 @@ onUnmounted(() => {
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);
} }
.form-section h2 { .form-section h2 {
font-family: 'Brygada 1918', serif; font-family: "Brygada 1918", serif;
font-size: 20px; font-size: 20px;
font-weight: 500; font-weight: 500;
color: var(--text-bright); color: var(--text-bright);
@ -739,9 +807,9 @@ onUnmounted(() => {
margin-bottom: 24px; margin-bottom: 24px;
line-height: 1.65; line-height: 1.65;
} }
.form-grid { .form-stack {
display: grid; display: flex;
grid-template-columns: 1fr 1fr; flex-direction: column;
gap: 20px; gap: 20px;
max-width: 600px; max-width: 600px;
} }
@ -750,9 +818,6 @@ onUnmounted(() => {
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
} }
.form-group.full-width {
grid-column: 1 / -1;
}
.form-label { .form-label {
font-size: 10px; font-size: 10px;
letter-spacing: 0.1em; letter-spacing: 0.1em;
@ -763,7 +828,7 @@ onUnmounted(() => {
background: var(--surface); background: var(--surface);
border: 1px dashed var(--border); border: 1px dashed var(--border);
color: var(--text-bright); color: var(--text-bright);
font-family: 'Commit Mono', monospace; font-family: "Commit Mono", monospace;
font-size: 13px; font-size: 13px;
padding: 10px 14px; padding: 10px 14px;
transition: border-color 0.2s; transition: border-color 0.2s;
@ -835,7 +900,7 @@ onUnmounted(() => {
background: var(--surface); background: var(--surface);
border: 1px dashed var(--border); border: 1px dashed var(--border);
color: var(--text-bright); color: var(--text-bright);
font-family: 'Commit Mono', monospace; font-family: "Commit Mono", monospace;
font-size: 13px; font-size: 13px;
padding: 10px 14px; padding: 10px 14px;
transition: border-color 0.2s; transition: border-color 0.2s;
@ -861,7 +926,7 @@ onUnmounted(() => {
display: inline-block; display: inline-block;
background: var(--parch); background: var(--parch);
color: var(--parch-accent); color: var(--parch-accent);
font-family: 'Commit Mono', monospace; font-family: "Commit Mono", monospace;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
@ -965,7 +1030,7 @@ onUnmounted(() => {
margin-bottom: 8px; margin-bottom: 8px;
} }
.info-value { .info-value {
font-family: 'Brygada 1918', serif; font-family: "Brygada 1918", serif;
font-size: 18px; font-size: 18px;
font-weight: 500; font-weight: 500;
color: var(--text-bright); color: var(--text-bright);
@ -989,14 +1054,12 @@ onUnmounted(() => {
.content-block:last-child { .content-block:last-child {
border-bottom: none; border-bottom: none;
} }
.tier-row { .join-two-col {
grid-template-columns: repeat(3, 1fr);
}
.form-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.form-group.full-width { .join-col:first-child {
grid-column: auto; border-right: none;
border-bottom: 1px dashed var(--border);
} }
.circle-radios { .circle-radios {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -1020,9 +1083,6 @@ onUnmounted(() => {
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.tier-row {
grid-template-columns: repeat(2, 1fr);
}
.button-row { .button-row {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;

View file

@ -216,31 +216,28 @@ const newEmail = ref("");
const isUpdatingEmail = ref(false); const isUpdatingEmail = ref(false);
const tiers = [ const tiers = [
{ amount: 0, display: "$0", label: "Solidarity" }, { amount: 0, display: "$0", label: "I need support right now" },
{ amount: 5, display: "$5", label: "Supporter" }, { amount: 5, display: "$5", label: "I can contribute" },
{ amount: 15, display: "$15", label: "Sustainer" }, { amount: 15, display: "$15", label: "I can sustain the community" },
{ amount: 30, display: "$30", label: "Builder" }, { amount: 30, display: "$30", label: "I can support others too" },
{ amount: 50, display: "$50", label: "Champion" }, { amount: 50, display: "$50", label: "I want to sponsor multiple members" },
]; ];
const circleOptions = [ const circleOptions = [
{ {
value: "community", value: "community",
label: "Community", label: "Community",
description: description: "Exploring cooperative ideas",
"For anyone interested in cooperative game dev. Access discussions, events, and resources.",
}, },
{ {
value: "founder", value: "founder",
label: "Founder", label: "Founder",
description: description: "Building a cooperative studio",
"For those actively building or running a cooperative studio. Peer support and deep dives.",
}, },
{ {
value: "practitioner", value: "practitioner",
label: "Practitioner", label: "Practitioner",
description: description: "Experienced in cooperative practice",
"For professionals advising co-ops: lawyers, accountants, facilitators, consultants.",
}, },
]; ];

View file

@ -1,7 +1,7 @@
<template> <template>
<PageShell <PageShell
title="Activity Log" title="Activity Log"
subtitle="Your activity and milestones in the Guild" subtitle="Your recent activity"
> >
<ColumnsLayout cols="events-sidebar" :limit="5"> <ColumnsLayout cols="events-sidebar" :limit="5">
<ClientOnly> <ClientOnly>

View file

@ -32,178 +32,178 @@
<!-- Member Status Banner --> <!-- Member Status Banner -->
<MemberStatusBanner /> <MemberStatusBanner />
<!-- Welcome Header --> <!-- Welcome Header -->
<PageHeader :title="`Welcome back, ${memberData?.name || ''}`"> <PageHeader :title="`Welcome back, ${memberData?.name || ''}`">
<div class="dashboard-meta"> <div class="dashboard-meta">
<CircleBadge :circle="memberData?.circle || 'community'" /> <CircleBadge :circle="memberData?.circle || 'community'" />
<span>${{ memberData?.contributionTier }} CAD/mo</span> <span>${{ memberData?.contributionTier }} CAD/mo</span>
</div>
</PageHeader>
<!-- Upcoming Events + Quick Actions -->
<div class="content-row">
<div class="content-block">
<div class="section-label">Your Upcoming Events</div>
<div v-if="loadingEvents" class="loading-inline">
<div class="spinner spinner-sm" />
</div> </div>
</PageHeader>
<!-- Upcoming Events + Quick Actions --> <div v-else-if="registeredEvents.length" class="event-list">
<div class="content-row"> <NuxtLink
<div class="content-block"> v-for="evt in registeredEvents"
<div class="section-label">Your Upcoming Events</div> :key="evt._id"
:to="`/events/${evt.slug || evt._id}`"
class="event-item"
>
<span class="event-date">{{
formatEventDate(evt.startDate)
}}</span>
<span class="event-title">{{ evt.title }}</span>
<CircleBadge v-if="evt.circle" :circle="evt.circle" />
</NuxtLink>
<div v-if="loadingEvents" class="loading-inline"> <!-- Calendar subscription -->
<div class="spinner spinner-sm" /> <button class="calendar-btn" @click="copyCalendarLink">
</div> {{
calendarLinkCopied
? "Link copied!"
: "Subscribe to calendar"
}}
</button>
</div>
<div v-else-if="registeredEvents.length" class="event-list"> <div v-else class="empty-state">
<NuxtLink <p>You haven't registered for any upcoming events</p>
v-for="evt in registeredEvents" </div>
:key="evt._id"
:to="`/events/${evt.slug || evt._id}`" <NuxtLink to="/events" class="section-link"
class="event-item" >Browse all events &rarr;</NuxtLink
>
<!-- Calendar subscription instructions -->
<div
v-if="registeredEvents.length > 0 && showCalendarInstructions"
class="calendar-instructions"
>
<div class="ci-header">
<strong>How to Subscribe to Your Calendar</strong>
<button
type="button"
@click="showCalendarInstructions = false"
class="ci-close"
> >
<span class="event-date">{{ &times;
formatEventDate(evt.startDate)
}}</span>
<span class="event-title">{{ evt.title }}</span>
<CircleBadge v-if="evt.circle" :circle="evt.circle" />
</NuxtLink>
<!-- Calendar subscription -->
<button class="calendar-btn" @click="copyCalendarLink">
{{
calendarLinkCopied
? "Link copied!"
: "Subscribe to calendar"
}}
</button> </button>
</div> </div>
<ul>
<div v-else class="empty-state"> <li>
<p>You haven't registered for any upcoming events</p> <strong>Google Calendar:</strong> Click "+" then "From URL"
</div> then paste the link
</li>
<NuxtLink to="/events" class="section-link" <li>
>Browse all events &rarr;</NuxtLink <strong>Apple Calendar:</strong> File then New Calendar
> Subscription then paste the link
</li>
<!-- Calendar subscription instructions --> <li>
<div <strong>Outlook:</strong> Add Calendar then Subscribe from
v-if="registeredEvents.length > 0 && showCalendarInstructions" web then paste the link
class="calendar-instructions" </li>
> </ul>
<div class="ci-header"> <p class="ci-note">
<strong>How to Subscribe to Your Calendar</strong> Your calendar will automatically update when you register or
<button unregister from events.
type="button" </p>
@click="showCalendarInstructions = false"
class="ci-close"
>
&times;
</button>
</div>
<ul>
<li>
<strong>Google Calendar:</strong> Click "+" then "From
URL" then paste the link
</li>
<li>
<strong>Apple Calendar:</strong> File then New Calendar
Subscription then paste the link
</li>
<li>
<strong>Outlook:</strong> Add Calendar then Subscribe from
web then paste the link
</li>
</ul>
<p class="ci-note">
Your calendar will automatically update when you register or
unregister from events.
</p>
</div>
</div>
<div class="content-block">
<div class="section-label">Quick Actions</div>
<NuxtLink
to="/ecology"
class="quick-action"
:class="{ disabled: !canPeerSupport }"
:title="
!canPeerSupport
? 'Complete your membership to access community ecology'
: ''
"
>
Community ecology<span class="arrow">&rarr;</span>
</NuxtLink>
<NuxtLink to="/member/profile" class="quick-action">
Update your profile<span class="arrow">&rarr;</span>
</NuxtLink>
<a
href="https://wiki.ghostguild.org"
target="_blank"
class="quick-action"
@click="handleWikiClick"
>
Browse the wiki<span class="arrow">&rarr;</span>
</a>
<NuxtLink to="/members" class="quick-action">
Browse members<span class="arrow">&rarr;</span>
</NuxtLink>
<NuxtLink to="/member/profile#account" class="quick-action">
Manage account<span class="arrow">&rarr;</span>
</NuxtLink>
</div> </div>
</div> </div>
<!-- Membership Summary + Peer Support --> <div class="content-block">
<div class="content-row"> <div class="section-label">Quick Actions</div>
<div class="content-block"> <NuxtLink
<div class="section-label">Your Membership</div> to="/ecology"
<div class="membership-row"> class="quick-action"
<span class="key">Circle</span> :class="{ disabled: !canPeerSupport }"
<span :title="
class="val" !canPeerSupport
:style="{ ? 'Complete your membership to access community ecology'
color: `var(--c-${memberData?.circle || 'community'})`, : ''
}" "
> >
{{ memberData?.circle }} Community ecology<span class="arrow">&rarr;</span>
</span> </NuxtLink>
</div> <NuxtLink to="/member/profile" class="quick-action">
<div class="membership-row"> Update your profile<span class="arrow">&rarr;</span>
<span class="key">Contribution</span> </NuxtLink>
<span class="val" <a
>${{ memberData?.contributionTier }} CAD/month</span href="https://wiki.ghostguild.org"
> target="_blank"
</div> class="quick-action"
<div class="membership-row"> @click="handleWikiClick"
<span class="key">Status</span> >
<span class="val"> Browse the wiki<span class="arrow">&rarr;</span>
<span :class="isActive ? 'status-active' : ''"> </a>
{{ isActive ? "Active" : statusConfig.label }} <NuxtLink to="/members" class="quick-action">
</span> Browse members<span class="arrow">&rarr;</span>
</span> </NuxtLink>
</div> <NuxtLink to="/member/account" class="quick-action">
<div v-if="memberData?.createdAt" class="membership-row"> Manage account<span class="arrow">&rarr;</span>
<span class="key">Member since</span> </NuxtLink>
<span class="val">{{
formatMemberSince(memberData.createdAt)
}}</span>
</div>
<NuxtLink to="/member/profile#account" class="section-link">
Change circle or contribution &rarr;
</NuxtLink>
</div>
<div class="content-block">
<div class="section-label">Community</div>
<DashedBox>
<p class="peer-text">
Connect with other members through shared interests and
cooperative topics.
</p>
<NuxtLink to="/ecology" class="section-link">
Browse community ecology &rarr;
</NuxtLink>
</DashedBox>
</div>
</div> </div>
</div>
<!-- Membership Summary + Peer Support -->
<div class="content-row">
<div class="content-block">
<div class="section-label">Your Membership</div>
<div class="membership-row">
<span class="key">Circle</span>
<span
class="val"
:style="{
color: `var(--c-${memberData?.circle || 'community'})`,
}"
>
{{ memberData?.circle }}
</span>
</div>
<div class="membership-row">
<span class="key">Contribution</span>
<span class="val"
>${{ memberData?.contributionTier }} CAD/month</span
>
</div>
<div class="membership-row">
<span class="key">Status</span>
<span class="val">
<span :class="isActive ? 'status-active' : ''">
{{ isActive ? "Active" : statusConfig.label }}
</span>
</span>
</div>
<div v-if="memberData?.createdAt" class="membership-row">
<span class="key">Member since</span>
<span class="val">{{
formatMemberSince(memberData.createdAt)
}}</span>
</div>
<NuxtLink to="/member/account" class="section-link">
Change circle or contribution &rarr;
</NuxtLink>
</div>
<div class="content-block">
<div class="section-label">Community</div>
<DashedBox>
<p class="peer-text">
Connect with other members through shared interests and
cooperative topics.
</p>
<NuxtLink to="/ecology" class="section-link">
Browse community ecology &rarr;
</NuxtLink>
</DashedBox>
</div>
</div>
</ColumnsLayout> </ColumnsLayout>
</template> </template>
@ -226,7 +226,7 @@ const { trackGoal, isComplete: onboardingComplete } = useOnboarding();
const handleWikiClick = () => { const handleWikiClick = () => {
if (!onboardingComplete.value) { if (!onboardingComplete.value) {
trackGoal('wikiClicked'); trackGoal("wikiClicked");
} }
}; };

View file

@ -200,6 +200,8 @@
</template> </template>
<script setup> <script setup>
definePageMeta({ middleware: ["members-auth"] });
import { formatActivity } from '~/utils/activityText' import { formatActivity } from '~/utils/activityText'
const route = useRoute(); const route = useRoute();

View file

@ -3,7 +3,7 @@
<!-- Page Header --> <!-- Page Header -->
<PageHeader <PageHeader
title="Event Series" title="Event Series"
subtitle="Discover our multi-event series designed to take you on a journey of learning and growth" subtitle="Multi-session events on cooperative topics"
/> />
<!-- Series Grid --> <!-- Series Grid -->
@ -178,11 +178,11 @@
class="w-16 h-16 text-[--ui-text-muted] mx-auto mb-4 opacity-50" class="w-16 h-16 text-[--ui-text-muted] mx-auto mb-4 opacity-50"
/> />
<h3 class="text-display-sm font-semibold text-[--ui-text] mb-2"> <h3 class="text-display-sm font-semibold text-[--ui-text] mb-2">
No Event Series Available No series right now
</h3> </h3>
<p class="text-[--ui-text-muted] max-w-md mx-auto"> <p class="text-[--ui-text-muted] max-w-md mx-auto">
We're currently planning exciting event series. Check back soon for Check back later or browse
multi-event learning journeys! <NuxtLink to="/events" class="text-primary">upcoming events</NuxtLink>.
</p> </p>
</div> </div>
</UContainer> </UContainer>
@ -198,7 +198,7 @@ useHead({
{ {
name: "description", name: "description",
content: content:
"Discover our multi-event series designed to take you on a journey of learning and growth in cooperative game development and community building.", "Multi-session events on cooperative topics for game developers.",
}, },
], ],
}); });

View file

@ -2,23 +2,35 @@ import * as z from 'zod'
import WikiArticle from '../../../models/wikiArticle.js' import WikiArticle from '../../../models/wikiArticle.js'
import { connectDB } from '../../../utils/mongoose.js' import { connectDB } from '../../../utils/mongoose.js'
const wikiTagsSchema = z.object({ const wikiUpdateSchema = z.object({
tags: z.array(z.string()) tags: z.array(z.string()).optional(),
hidden: z.boolean().optional()
}) })
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireAdmin(event) await requireAdmin(event)
const body = await validateBody(event, wikiTagsSchema) const body = await validateBody(event, wikiUpdateSchema)
const id = getRouterParam(event, 'id') const id = getRouterParam(event, 'id')
if (body.tags === undefined && body.hidden === undefined) {
throw createError({ statusCode: 400, statusMessage: 'Nothing to update' })
}
await connectDB() await connectDB()
await validateTagSlugs(body.tags) const update = {}
if (body.tags !== undefined) {
await validateTagSlugs(body.tags)
update.tags = body.tags
}
if (body.hidden !== undefined) {
update.hidden = body.hidden
}
const article = await WikiArticle.findByIdAndUpdate( const article = await WikiArticle.findByIdAndUpdate(
id, id,
{ tags: body.tags }, update,
{ new: true } { new: true }
) )

View file

@ -0,0 +1,34 @@
import * as z from 'zod'
import WikiArticle from '../../../models/wikiArticle.js'
import { connectDB } from '../../../utils/mongoose.js'
const batchVisibilitySchema = z.object({
articleIds: z.array(z.string()).optional(),
collection: z.string().optional(),
hidden: z.boolean()
})
export default defineEventHandler(async (event) => {
await requireAdmin(event)
const body = await validateBody(event, batchVisibilitySchema)
if (!body.articleIds && !body.collection) {
throw createError({
statusCode: 400,
statusMessage: 'Must provide either articleIds or collection'
})
}
await connectDB()
const filter = body.articleIds
? { _id: { $in: body.articleIds } }
: { collection: body.collection }
const result = await WikiArticle.updateMany(filter, {
$set: { hidden: body.hidden }
})
return { modified: result.modifiedCount || 0 }
})

View file

@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
} }
const articles = await WikiArticle.find(filter) const articles = await WikiArticle.find(filter)
.select('collection title tags url outlineId publishedAt') .select('collection title tags hidden url outlineId publishedAt outlineUpdatedAt')
.sort({ collection: 1, title: 1 }) .sort({ collection: 1, title: 1 })
.lean() .lean()

View file

@ -1,14 +1,10 @@
import WikiArticle from '../../../models/wikiArticle.js' import { syncWikiArticles } from '../../../utils/syncWikiArticles.js'
import { connectDB } from '../../../utils/mongoose.js'
import { fetchAllDocuments, extractSummary } from '../../../utils/outline.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireAdmin(event) await requireAdmin(event)
// Fetch ALL documents first — if this fails, no DB changes happen
let documents
try { try {
documents = await fetchAllDocuments() return await syncWikiArticles()
} catch (err) { } catch (err) {
console.error('[wiki-sync] Outline fetch failed:', err) console.error('[wiki-sync] Outline fetch failed:', err)
throw createError({ throw createError({
@ -16,68 +12,4 @@ export default defineEventHandler(async (event) => {
statusMessage: err.statusMessage || 'Failed to fetch documents from Outline' statusMessage: err.statusMessage || 'Failed to fetch documents from Outline'
}) })
} }
await connectDB()
const fetchedOutlineIds = new Set(documents.map((doc) => doc.id))
// Get all existing articles for comparison
const existing = await WikiArticle.find({}, 'outlineId publishedAt')
const existingByOutlineId = new Map(
existing.map((a) => [a.outlineId, a])
)
let created = 0
let updated = 0
let deleted = 0
let errors = 0
// Upsert each fetched document
for (const doc of documents) {
try {
const articleData = {
title: doc.title,
collection: doc.collection?.name || null,
url: doc.url,
summary: extractSummary(doc.text),
publishedAt: doc.publishedAt ? new Date(doc.publishedAt) : new Date(doc.createdAt),
permission: doc.permission || null,
lastSyncedAt: new Date(),
outlineUpdatedAt: doc.updatedAt ? new Date(doc.updatedAt) : null
}
const result = await WikiArticle.findOneAndUpdate(
{ outlineId: doc.id },
{ $set: articleData },
{ upsert: true, new: true, rawResult: true }
)
if (result.lastErrorObject?.updatedExisting) {
updated++
} else {
created++
}
} catch (err) {
console.error(`[wiki-sync] Error upserting doc ${doc.id}:`, err)
errors++
}
}
// Soft-delete articles no longer in Outline
for (const [outlineId, article] of existingByOutlineId) {
if (!fetchedOutlineIds.has(outlineId) && article.publishedAt !== null) {
try {
await WikiArticle.findOneAndUpdate(
{ outlineId },
{ $set: { publishedAt: null, lastSyncedAt: new Date() } }
)
deleted++
} catch (err) {
console.error(`[wiki-sync] Error soft-deleting ${outlineId}:`, err)
errors++
}
}
}
return { created, updated, deleted, errors }
}) })

View file

@ -0,0 +1,20 @@
import WikiArticle from '../../models/wikiArticle.js'
import { connectDB } from '../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
await connectDB()
const query = getQuery(event)
const limit = Math.min(Math.max(parseInt(query.limit) || 4, 1), 10)
const articles = await WikiArticle.find({
publishedAt: { $ne: null },
hidden: { $ne: true }
})
.sort({ publishedAt: -1 })
.limit(limit)
.select('title url publishedAt')
.lean()
return articles
})

View file

@ -8,6 +8,7 @@ const wikiArticleSchema = new mongoose.Schema(
url: { type: String, required: true }, url: { type: String, required: true },
summary: String, summary: String,
tags: [{ type: String }], tags: [{ type: String }],
hidden: { type: Boolean, default: false },
publishedAt: Date, publishedAt: Date,
permission: String, permission: String,
lastSyncedAt: Date, lastSyncedAt: Date,

View file

@ -0,0 +1,26 @@
import { syncWikiArticles } from '../utils/syncWikiArticles.js'
const INTERVAL_MS = 86400000 // 24 hours
export default defineNitroPlugin(() => {
if (process.env.NODE_ENV === 'test') return
const config = useRuntimeConfig()
if (!config.outlineApiKey) {
console.warn('[wiki-sync] No Outline API key configured, skipping background sync')
return
}
async function run() {
try {
const result = await syncWikiArticles()
console.log(`[wiki-sync] Done: ${result.created} created, ${result.updated} updated, ${result.deleted} removed, ${result.errors} errors`)
} catch (err) {
console.error('[wiki-sync] Unhandled error:', err.message || err)
}
}
// Run on server start, then every 24 hours
run()
setInterval(run, INTERVAL_MS)
})

View file

@ -36,21 +36,22 @@ export async function fetchAllDocuments() {
} }
const documents = [] const documents = []
let path = '/documents.list' let offset = 0
const limit = 25
while (path) { while (true) {
const response = await fetch(`${OUTLINE_API_BASE}${path}`, { const response = await fetch(`${OUTLINE_API_BASE}/documents.list`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}` Authorization: `Bearer ${apiKey}`
}, },
body: JSON.stringify({ limit: 25 }) body: JSON.stringify({ offset, limit })
}) })
if (!response.ok) { if (!response.ok) {
const errorText = await response.text() const errorText = await response.text()
console.error(`[outline] POST ${path} ${response.status} ${errorText}`) console.error(`[outline] POST /documents.list offset=${offset} ${response.status} ${errorText}`)
throw createError({ throw createError({
statusCode: response.status, statusCode: response.status,
statusMessage: 'Outline API error' statusMessage: 'Outline API error'
@ -58,10 +59,46 @@ export async function fetchAllDocuments() {
} }
const data = await response.json() const data = await response.json()
documents.push(...(data.data || [])) const page = data.data || []
documents.push(...page)
path = data.pagination?.nextPath || null if (page.length < limit) break
offset += limit
} }
return documents return documents
} }
export async function fetchCollections() {
const config = useRuntimeConfig()
const apiKey = config.outlineApiKey
if (!apiKey) {
throw createError({
statusCode: 500,
statusMessage: 'Outline API key not configured'
})
}
const response = await fetch(`${OUTLINE_API_BASE}/collections.list`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`
},
body: JSON.stringify({ limit: 100 })
})
if (!response.ok) {
const errorText = await response.text()
console.error(`[outline] POST /collections.list ${response.status} ${errorText}`)
throw createError({
statusCode: response.status,
statusMessage: 'Outline API error'
})
}
const data = await response.json()
const collections = data.data || []
return new Map(collections.map(c => [c.id, c.name]))
}

View file

@ -0,0 +1,72 @@
import WikiArticle from '../models/wikiArticle.js'
import { connectDB } from './mongoose.js'
import { fetchAllDocuments, fetchCollections, extractSummary } from './outline.js'
export async function syncWikiArticles() {
const [documents, collectionMap] = await Promise.all([
fetchAllDocuments(),
fetchCollections()
])
await connectDB()
const fetchedOutlineIds = new Set(documents.map((doc) => doc.id))
const existing = await WikiArticle.find({}, 'outlineId publishedAt')
const existingByOutlineId = new Map(
existing.map((a) => [a.outlineId, a])
)
let created = 0
let updated = 0
let deleted = 0
let errors = 0
for (const doc of documents) {
try {
// Only $set fields from Outline — tags are never touched
const articleData = {
title: doc.title,
collection: collectionMap.get(doc.collectionId) || null,
url: doc.url?.startsWith('/') ? `https://wiki.ghostguild.org${doc.url}` : doc.url,
summary: extractSummary(doc.text),
publishedAt: doc.publishedAt ? new Date(doc.publishedAt) : new Date(doc.createdAt),
permission: doc.permission || null,
lastSyncedAt: new Date(),
outlineUpdatedAt: doc.updatedAt ? new Date(doc.updatedAt) : null
}
const result = await WikiArticle.findOneAndUpdate(
{ outlineId: doc.id },
{ $set: articleData },
{ upsert: true, new: true, rawResult: true }
)
if (result.lastErrorObject?.updatedExisting) {
updated++
} else {
created++
}
} catch (err) {
console.error(`[wiki-sync] Error upserting doc ${doc.id}:`, err)
errors++
}
}
for (const [outlineId, article] of existingByOutlineId) {
if (!fetchedOutlineIds.has(outlineId) && article.publishedAt !== null) {
try {
await WikiArticle.findOneAndUpdate(
{ outlineId },
{ $set: { publishedAt: null, lastSyncedAt: new Date() } }
)
deleted++
} catch (err) {
console.error(`[wiki-sync] Error soft-deleting ${outlineId}:`, err)
errors++
}
}
}
return { created, updated, deleted, errors }
}

View file

@ -1,218 +1,235 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from "vitest";
import { ref, computed } from 'vue' import { ref, computed } from "vue";
import { MEMBER_STATUSES, MEMBER_STATUS_CONFIG, useMemberStatus } from '../../../app/composables/useMemberStatus.js' import {
MEMBER_STATUSES,
MEMBER_STATUS_CONFIG,
useMemberStatus,
} from "../../../app/composables/useMemberStatus.js";
// Stub Vue's computed as a global (Nuxt auto-import) // Stub Vue's computed as a global (Nuxt auto-import)
vi.stubGlobal('computed', computed) vi.stubGlobal("computed", computed);
// Shared reactive ref for controlling member status in tests // Shared reactive ref for controlling member status in tests
const memberData = ref({ status: 'active' }) const memberData = ref({ status: "active" });
vi.stubGlobal('useAuth', () => ({ memberData })) vi.stubGlobal("useAuth", () => ({ memberData }));
describe('MEMBER_STATUSES', () => { describe("MEMBER_STATUSES", () => {
it('has all four status keys', () => { it("has all four status keys", () => {
expect(Object.keys(MEMBER_STATUSES)).toEqual([ expect(Object.keys(MEMBER_STATUSES)).toEqual([
'PENDING_PAYMENT', 'ACTIVE', 'SUSPENDED', 'CANCELLED', "PENDING_PAYMENT",
]) "ACTIVE",
}) "SUSPENDED",
"CANCELLED",
]);
});
it('maps to expected string values', () => { it("maps to expected string values", () => {
expect(MEMBER_STATUSES.PENDING_PAYMENT).toBe('pending_payment') expect(MEMBER_STATUSES.PENDING_PAYMENT).toBe("pending_payment");
expect(MEMBER_STATUSES.ACTIVE).toBe('active') expect(MEMBER_STATUSES.ACTIVE).toBe("active");
expect(MEMBER_STATUSES.SUSPENDED).toBe('suspended') expect(MEMBER_STATUSES.SUSPENDED).toBe("suspended");
expect(MEMBER_STATUSES.CANCELLED).toBe('cancelled') expect(MEMBER_STATUSES.CANCELLED).toBe("cancelled");
}) });
}) });
describe('MEMBER_STATUS_CONFIG', () => { describe("MEMBER_STATUS_CONFIG", () => {
const requiredFields = ['label', 'color', 'canRSVP', 'canAccessMembers', 'canPeerSupport'] const requiredFields = [
"label",
"color",
"canRSVP",
"canAccessMembers",
"canPeerSupport",
];
it('has config for every status value', () => { it("has config for every status value", () => {
for (const status of Object.values(MEMBER_STATUSES)) { for (const status of Object.values(MEMBER_STATUSES)) {
expect(MEMBER_STATUS_CONFIG).toHaveProperty(status) expect(MEMBER_STATUS_CONFIG).toHaveProperty(status);
} }
}) });
it('each config has required fields', () => { it("each config has required fields", () => {
for (const [key, config] of Object.entries(MEMBER_STATUS_CONFIG)) { for (const [key, config] of Object.entries(MEMBER_STATUS_CONFIG)) {
for (const field of requiredFields) { for (const field of requiredFields) {
expect(config, `${key} missing ${field}`).toHaveProperty(field) expect(config, `${key} missing ${field}`).toHaveProperty(field);
} }
} }
}) });
it('active has full permissions', () => { it("active has full permissions", () => {
const cfg = MEMBER_STATUS_CONFIG.active const cfg = MEMBER_STATUS_CONFIG.active;
expect(cfg.canRSVP).toBe(true) expect(cfg.canRSVP).toBe(true);
expect(cfg.canAccessMembers).toBe(true) expect(cfg.canAccessMembers).toBe(true);
expect(cfg.canPeerSupport).toBe(true) expect(cfg.canPeerSupport).toBe(true);
}) });
it('pending_payment can access members but not RSVP or peer support', () => { it("pending_payment can access members but not RSVP or peer support", () => {
const cfg = MEMBER_STATUS_CONFIG.pending_payment const cfg = MEMBER_STATUS_CONFIG.pending_payment;
expect(cfg.canRSVP).toBe(false) expect(cfg.canRSVP).toBe(false);
expect(cfg.canAccessMembers).toBe(true) expect(cfg.canAccessMembers).toBe(true);
expect(cfg.canPeerSupport).toBe(false) expect(cfg.canPeerSupport).toBe(false);
}) });
it('suspended has all permissions false', () => { it("suspended has all permissions false", () => {
const cfg = MEMBER_STATUS_CONFIG.suspended const cfg = MEMBER_STATUS_CONFIG.suspended;
expect(cfg.canRSVP).toBe(false) expect(cfg.canRSVP).toBe(false);
expect(cfg.canAccessMembers).toBe(false) expect(cfg.canAccessMembers).toBe(false);
expect(cfg.canPeerSupport).toBe(false) expect(cfg.canPeerSupport).toBe(false);
}) });
it('cancelled has all permissions false', () => { it("cancelled has all permissions false", () => {
const cfg = MEMBER_STATUS_CONFIG.cancelled const cfg = MEMBER_STATUS_CONFIG.cancelled;
expect(cfg.canRSVP).toBe(false) expect(cfg.canRSVP).toBe(false);
expect(cfg.canAccessMembers).toBe(false) expect(cfg.canAccessMembers).toBe(false);
expect(cfg.canPeerSupport).toBe(false) expect(cfg.canPeerSupport).toBe(false);
}) });
}) });
describe('useMemberStatus composable', () => { describe("useMemberStatus composable", () => {
beforeEach(() => { beforeEach(() => {
memberData.value = { status: 'active' } memberData.value = { status: "active" };
}) });
describe('status detection', () => { describe("status detection", () => {
it('defaults to pending_payment when memberData has no status', () => { it("defaults to pending_payment when memberData has no status", () => {
memberData.value = {} memberData.value = {};
const { status } = useMemberStatus() const { status } = useMemberStatus();
expect(status.value).toBe('pending_payment') expect(status.value).toBe("pending_payment");
}) });
it('defaults to pending_payment when memberData is null', () => { it("defaults to pending_payment when memberData is null", () => {
memberData.value = null memberData.value = null;
const { status } = useMemberStatus() const { status } = useMemberStatus();
expect(status.value).toBe('pending_payment') expect(status.value).toBe("pending_payment");
}) });
it('isActive is true when status is active', () => { it("isActive is true when status is active", () => {
memberData.value = { status: 'active' } memberData.value = { status: "active" };
const { isActive } = useMemberStatus() const { isActive } = useMemberStatus();
expect(isActive.value).toBe(true) expect(isActive.value).toBe(true);
}) });
it('isActive is false when status is not active', () => { it("isActive is false when status is not active", () => {
memberData.value = { status: 'suspended' } memberData.value = { status: "suspended" };
const { isActive, isInactive } = useMemberStatus() const { isActive, isInactive } = useMemberStatus();
expect(isActive.value).toBe(false) expect(isActive.value).toBe(false);
expect(isInactive.value).toBe(true) expect(isInactive.value).toBe(true);
}) });
}) });
describe('permissions', () => { describe("permissions", () => {
it('canRSVP is true when active', () => { it("canRSVP is true when active", () => {
memberData.value = { status: 'active' } memberData.value = { status: "active" };
const { canRSVP } = useMemberStatus() const { canRSVP } = useMemberStatus();
expect(canRSVP.value).toBe(true) expect(canRSVP.value).toBe(true);
}) });
it('canRSVP is false when pending_payment', () => { it("canRSVP is false when pending_payment", () => {
memberData.value = { status: 'pending_payment' } memberData.value = { status: "pending_payment" };
const { canRSVP } = useMemberStatus() const { canRSVP } = useMemberStatus();
expect(canRSVP.value).toBe(false) expect(canRSVP.value).toBe(false);
}) });
it('canAccessMembers is true for active and pending_payment', () => { it("canAccessMembers is true for active and pending_payment", () => {
for (const status of ['active', 'pending_payment']) { for (const status of ["active", "pending_payment"]) {
memberData.value = { status } memberData.value = { status };
const { canAccessMembers } = useMemberStatus() const { canAccessMembers } = useMemberStatus();
expect(canAccessMembers.value, `expected true for ${status}`).toBe(true) expect(canAccessMembers.value, `expected true for ${status}`).toBe(
true,
);
} }
}) });
it('canAccessMembers is false for suspended and cancelled', () => { it("canAccessMembers is false for suspended and cancelled", () => {
for (const status of ['suspended', 'cancelled']) { for (const status of ["suspended", "cancelled"]) {
memberData.value = { status } memberData.value = { status };
const { canAccessMembers } = useMemberStatus() const { canAccessMembers } = useMemberStatus();
expect(canAccessMembers.value, `expected false for ${status}`).toBe(false) expect(canAccessMembers.value, `expected false for ${status}`).toBe(
false,
);
} }
}) });
}) });
describe('getNextAction', () => { describe("getNextAction", () => {
it('returns Complete Payment for pending_payment', () => { it("returns Complete Payment for pending_payment", () => {
memberData.value = { status: 'pending_payment' } memberData.value = { status: "pending_payment" };
const { getNextAction } = useMemberStatus() const { getNextAction } = useMemberStatus();
const action = getNextAction() const action = getNextAction();
expect(action.label).toBe('Complete Payment') expect(action.label).toBe("Complete Payment");
expect(action.link).toBe('/member/profile#account') expect(action.link).toBe("/member/account");
}) });
it('returns Reactivate Membership for cancelled', () => { it("returns Reactivate Membership for cancelled", () => {
memberData.value = { status: 'cancelled' } memberData.value = { status: "cancelled" };
const { getNextAction } = useMemberStatus() const { getNextAction } = useMemberStatus();
const action = getNextAction() const action = getNextAction();
expect(action.label).toBe('Reactivate Membership') expect(action.label).toBe("Reactivate Membership");
expect(action.link).toBe('/member/profile#account') expect(action.link).toBe("/member/account");
}) });
it('returns Contact Support for suspended', () => { it("returns Contact Support for suspended", () => {
memberData.value = { status: 'suspended' } memberData.value = { status: "suspended" };
const { getNextAction } = useMemberStatus() const { getNextAction } = useMemberStatus();
const action = getNextAction() const action = getNextAction();
expect(action.label).toBe('Contact Support') expect(action.label).toBe("Contact Support");
expect(action.link).toBe('mailto:support@ghostguild.org') expect(action.link).toBe("mailto:support@ghostguild.org");
}) });
it('returns null for active', () => { it("returns null for active", () => {
memberData.value = { status: 'active' } memberData.value = { status: "active" };
const { getNextAction } = useMemberStatus() const { getNextAction } = useMemberStatus();
expect(getNextAction()).toBeNull() expect(getNextAction()).toBeNull();
}) });
}) });
describe('getBannerMessage', () => { describe("getBannerMessage", () => {
it('returns payment message for pending_payment', () => { it("returns payment message for pending_payment", () => {
memberData.value = { status: 'pending_payment' } memberData.value = { status: "pending_payment" };
const { getBannerMessage } = useMemberStatus() const { getBannerMessage } = useMemberStatus();
expect(getBannerMessage()).toContain('pending payment') expect(getBannerMessage()).toContain("pending payment");
}) });
it('returns suspended message for suspended', () => { it("returns suspended message for suspended", () => {
memberData.value = { status: 'suspended' } memberData.value = { status: "suspended" };
const { getBannerMessage } = useMemberStatus() const { getBannerMessage } = useMemberStatus();
expect(getBannerMessage()).toContain('suspended') expect(getBannerMessage()).toContain("suspended");
}) });
it('returns cancelled message for cancelled', () => { it("returns cancelled message for cancelled", () => {
memberData.value = { status: 'cancelled' } memberData.value = { status: "cancelled" };
const { getBannerMessage } = useMemberStatus() const { getBannerMessage } = useMemberStatus();
expect(getBannerMessage()).toContain('cancelled') expect(getBannerMessage()).toContain("cancelled");
}) });
it('returns null for active', () => { it("returns null for active", () => {
memberData.value = { status: 'active' } memberData.value = { status: "active" };
const { getBannerMessage } = useMemberStatus() const { getBannerMessage } = useMemberStatus();
expect(getBannerMessage()).toBeNull() expect(getBannerMessage()).toBeNull();
}) });
}) });
describe('getRSVPMessage', () => { describe("getRSVPMessage", () => {
it('returns payment message for pending_payment', () => { it("returns payment message for pending_payment", () => {
memberData.value = { status: 'pending_payment' } memberData.value = { status: "pending_payment" };
const { getRSVPMessage } = useMemberStatus() const { getRSVPMessage } = useMemberStatus();
expect(getRSVPMessage()).toContain('payment') expect(getRSVPMessage()).toContain("payment");
}) });
it('returns restriction message for suspended', () => { it("returns restriction message for suspended", () => {
memberData.value = { status: 'suspended' } memberData.value = { status: "suspended" };
const { getRSVPMessage } = useMemberStatus() const { getRSVPMessage } = useMemberStatus();
expect(getRSVPMessage()).toContain('reactivate') expect(getRSVPMessage()).toContain("reactivate");
}) });
it('returns restriction message for cancelled', () => { it("returns restriction message for cancelled", () => {
memberData.value = { status: 'cancelled' } memberData.value = { status: "cancelled" };
const { getRSVPMessage } = useMemberStatus() const { getRSVPMessage } = useMemberStatus();
expect(getRSVPMessage()).toContain('reactivate') expect(getRSVPMessage()).toContain("reactivate");
}) });
it('returns null for active', () => { it("returns null for active", () => {
memberData.value = { status: 'active' } memberData.value = { status: "active" };
const { getRSVPMessage } = useMemberStatus() const { getRSVPMessage } = useMemberStatus();
expect(getRSVPMessage()).toBeNull() expect(getRSVPMessage()).toBeNull();
}) });
}) });
}) });