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

View file

@ -26,7 +26,7 @@ defineProps({
default: () => [
{ value: 'community', label: 'Community', description: 'Learning together, exploring cooperative models' },
{ 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" */
.columns-events-sidebar {
grid-template-columns: 1fr 200px;
flex: 1;
}
/* Ensure grid children don't overflow */
@ -60,11 +61,14 @@ if (props.cols === 'events-sidebar') {
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-main {
border-right: 1px dashed var(--border);
}
.divider-dashed.columns-events-sidebar .col-main {
border-right: none;
}
/* Responsive collapse at 1024px (default) */
.collapse-1024 {

View file

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

View file

@ -4,137 +4,149 @@
*/
export const MEMBER_STATUSES = {
PENDING_PAYMENT: 'pending_payment',
ACTIVE: 'active',
SUSPENDED: 'suspended',
CANCELLED: 'cancelled',
}
PENDING_PAYMENT: "pending_payment",
ACTIVE: "active",
SUSPENDED: "suspended",
CANCELLED: "cancelled",
};
export const MEMBER_STATUS_CONFIG = {
pending_payment: {
label: 'Payment Pending',
color: 'orange',
bgColor: 'bg-orange-500/10',
borderColor: 'border-orange-500/30',
textColor: 'text-orange-300',
icon: 'heroicons:exclamation-triangle',
severity: 'warning',
label: "Payment Pending",
color: "orange",
bgColor: "bg-orange-500/10",
borderColor: "border-orange-500/30",
textColor: "text-orange-300",
icon: "heroicons:exclamation-triangle",
severity: "warning",
canRSVP: false,
canAccessMembers: true,
canPeerSupport: false,
},
active: {
label: 'Active Member',
color: 'green',
bgColor: 'bg-green-500/10',
borderColor: 'border-green-500/30',
textColor: 'text-green-300',
icon: 'heroicons:check-circle',
severity: 'success',
label: "Active Member",
color: "green",
bgColor: "bg-green-500/10",
borderColor: "border-green-500/30",
textColor: "text-green-300",
icon: "heroicons:check-circle",
severity: "success",
canRSVP: true,
canAccessMembers: true,
canPeerSupport: true,
},
suspended: {
label: 'Membership Suspended',
color: 'red',
bgColor: 'bg-red-500/10',
borderColor: 'border-red-500/30',
textColor: 'text-red-300',
icon: 'heroicons:no-symbol',
severity: 'error',
label: "Membership Suspended",
color: "red",
bgColor: "bg-red-500/10",
borderColor: "border-red-500/30",
textColor: "text-red-300",
icon: "heroicons:no-symbol",
severity: "error",
canRSVP: false,
canAccessMembers: false,
canPeerSupport: false,
},
cancelled: {
label: 'Membership Cancelled',
color: 'gray',
bgColor: 'bg-gray-500/10',
borderColor: 'border-gray-500/30',
textColor: 'text-gray-300',
icon: 'heroicons:x-circle',
severity: 'error',
label: "Membership Cancelled",
color: "gray",
bgColor: "bg-gray-500/10",
borderColor: "border-gray-500/30",
textColor: "text-gray-300",
icon: "heroicons:x-circle",
severity: "error",
canRSVP: false,
canAccessMembers: false,
canPeerSupport: false,
},
}
};
export const useMemberStatus = () => {
const { memberData } = useAuth()
const { memberData } = useAuth();
// 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
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
const isActive = computed(() => status.value === MEMBER_STATUSES.ACTIVE)
const isPendingPayment = computed(() => status.value === MEMBER_STATUSES.PENDING_PAYMENT)
const isSuspended = computed(() => status.value === MEMBER_STATUSES.SUSPENDED)
const isCancelled = computed(() => status.value === MEMBER_STATUSES.CANCELLED)
const isInactive = computed(() => !isActive.value)
const isActive = computed(() => status.value === MEMBER_STATUSES.ACTIVE);
const isPendingPayment = computed(
() => status.value === MEMBER_STATUSES.PENDING_PAYMENT,
);
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
const canRSVP = computed(() => statusConfig.value.canRSVP)
const canAccessMembers = computed(() => statusConfig.value.canAccessMembers)
const canPeerSupport = computed(() => statusConfig.value.canPeerSupport)
const canRSVP = computed(() => statusConfig.value.canRSVP);
const canAccessMembers = computed(() => statusConfig.value.canAccessMembers);
const canPeerSupport = computed(() => statusConfig.value.canPeerSupport);
// Get action button text and link based on status
const getNextAction = () => {
if (isPendingPayment.value) {
return {
label: 'Complete Payment',
link: '/member/profile#account',
icon: 'heroicons:credit-card',
color: 'orange',
}
label: "Complete Payment",
link: "/member/account",
icon: "heroicons:credit-card",
color: "orange",
};
}
if (isCancelled.value) {
return {
label: 'Reactivate Membership',
link: '/member/profile#account',
icon: 'heroicons:arrow-path',
color: 'blue',
}
label: "Reactivate Membership",
link: "/member/account",
icon: "heroicons:arrow-path",
color: "blue",
};
}
if (isSuspended.value) {
return {
label: 'Contact Support',
link: 'mailto:support@ghostguild.org',
icon: 'heroicons:envelope',
color: 'gray',
}
label: "Contact Support",
link: "mailto:support@ghostguild.org",
icon: "heroicons:envelope",
color: "gray",
};
}
return null
}
return null;
};
// Get banner message based on status
const getBannerMessage = () => {
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) {
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) {
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
const getRSVPMessage = () => {
if (isPendingPayment.value) {
return 'Complete your payment to register for events'
return "Complete your payment to register for events";
}
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 {
status,
@ -151,5 +163,5 @@ export const useMemberStatus = () => {
getBannerMessage,
getRSVPMessage,
MEMBER_STATUSES,
}
}
};
};

View file

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

View file

@ -130,13 +130,13 @@
v-model="form.contributionTier"
class="form-select"
>
<option value="0">$0/mo -- Access is a right</option>
<option value="5">$5/mo -- A small gesture</option>
<option value="15">$15/mo -- Sustaining (suggested)</option>
<option value="30">$30/mo -- Supporting</option>
<option value="50">$50/mo -- Solidarity</option>
<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>
<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 class="form-group full-width">

View file

@ -45,6 +45,20 @@
<option v-for="col in collections" :key="col" :value="col">{{ col }}</option>
</select>
</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>
<!-- Batch Action Bar -->
@ -74,6 +88,22 @@
{{ batchApplying ? 'Applying...' : 'Apply' }}
</button>
</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>
</div>
@ -89,109 +119,390 @@
<!-- Article Table -->
<div v-else class="table-wrap">
<table v-if="filtered.length">
<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="sortable" @click="toggleSort('collection')">
Collection
<span v-if="sortKey === 'collection'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th class="sortable col-title" @click="toggleSort('title')">
Title
<span v-if="sortKey === 'title'" class="sort-arrow">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
<th>Tags</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) }"
@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-collection">{{ article.collection || '—' }}</td>
<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)"
<!-- Grouped view -->
<template v-if="groupBy !== 'none' && visibleGroups.length">
<details
v-for="group in visibleGroups"
:key="group.name"
class="collection-group"
>
<summary class="collection-header">
<span class="collection-name">{{ group.name }}</span>
<span class="collection-count">{{ group.articles.length }}</span>
</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)"
>
Edit tags
</button>
</td>
</tr>
</tbody>
</table>
<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-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">
No articles found matching your criteria
</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>
</template>
@ -229,6 +540,8 @@ const tagLabel = (slug) => tagLabelMap.value[slug] || slug
// ---- Filters & Sort ----
const searchQuery = ref('')
const collectionFilter = ref('')
const visibilityFilter = ref('')
const groupBy = ref('collection')
const sortKey = ref('')
const sortDir = ref('asc')
@ -257,7 +570,10 @@ const filtered = computed(() => {
const q = searchQuery.value.toLowerCase()
const matchesSearch = !q || a.title.toLowerCase().includes(q)
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) {
@ -273,6 +589,58 @@ const filtered = computed(() => {
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 ----
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 = () => {
if (!collectionFilter.value || !articles.value) return
const currentSet = new Set(selectedIds.value)
@ -442,6 +832,73 @@ const applyBatchTag = async () => {
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>
<style scoped>
@ -566,9 +1023,109 @@ const applyBatchTag = async () => {
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-wrap {
padding: 0 28px 24px;
padding: 0 0 24px;
}
table {
@ -690,6 +1247,49 @@ tbody td {
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 ---- */
.col-collection {
font-size: 11px;

View file

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

View file

@ -57,7 +57,7 @@
</div>
<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>
</UForm>
</div>

View file

@ -216,6 +216,7 @@
<!-- Not Logged In -->
<div v-else class="dashed-box">
<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">
<div class="field">
<label>Name</label>
@ -689,6 +690,11 @@ useHead(() => ({
color: var(--text-faint);
margin-bottom: 10px;
}
.reg-open {
font-size: 12px;
color: var(--text-dim);
margin-bottom: 10px;
}
.cal-link {
display: block;
margin-top: 8px;

View file

@ -5,7 +5,7 @@
<h1>Events</h1>
<p>
Workshops, meetups, and gatherings for game developers practicing
cooperative models.
cooperative models. Some events are open to the public.
</p>
</div>
@ -59,7 +59,7 @@
<div class="event-badges">
<span v-if="event.membersOnly" class="members-badge">Members</span>
<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 v-if="!filteredEvents?.length" class="empty">No events found</div>
@ -93,31 +93,6 @@
</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>
</template>
@ -382,34 +357,6 @@ const isAlmostFull = (event) => {
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 {
display: flex;

View file

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

View file

@ -3,7 +3,10 @@
<!-- HERO -->
<div class="hero">
<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>
<!-- Already a member -->
@ -11,31 +14,46 @@
<div class="full-section">
<h2>You're already a member</h2>
<p class="section-intro">
Welcome back, {{ memberData?.name || 'member' }}. You're part of Ghost Guild in the
<span class="capitalize">{{ memberData?.circle || 'community' }}</span> circle.
Welcome back, {{ memberData?.name || "member" }}. You're part of Ghost
Guild in the
<span class="capitalize">{{
memberData?.circle || "community"
}}</span>
circle.
</p>
<div class="member-info-grid">
<DashedBox :hoverable="false">
<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 :hoverable="false">
<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>
</div>
<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>
</div>
</div>
<ParchmentInset>
<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>
<NuxtLink to="/member/profile" class="parchment-link">Update Membership Settings</NuxtLink>
<p>
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>
</template>
@ -45,183 +63,237 @@
<ParchmentInset>
<h2>How membership works</h2>
<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>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>Higher contributions create solidarity spots for those who need them</li>
<li>
Your contribution is what you can afford ($0--50+/month, separate
from your circle)
</li>
<li>
Higher contributions create solidarity spots for those who need them
</li>
</ul>
</ParchmentInset>
<!-- THREE CIRCLES -->
<div class="content-row">
<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>
<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 class="circle-not-sure">Not sure where you fit? Start with Community. You can always move later.</p>
<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 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>
<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 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>
<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>
<!-- CONTRIBUTION TIERS -->
<div class="full-section">
<div class="section-label" style="margin-bottom: 12px;">Monthly Contribution</div>
<h2>Pay what you can</h2>
<div class="tier-row">
<div class="tier-card">
<div class="tier-amount">$0</div>
<div class="tier-freq">/month</div>
<div class="tier-desc">Access is a right</div>
<!-- CONTRIBUTION + SIGN UP (two columns) -->
<div v-if="currentStep === 1" class="join-two-col">
<!-- Left: Monthly Contribution -->
<div class="join-col">
<div class="section-label" style="margin-bottom: 12px">
Monthly Contribution
</div>
<div class="tier-card">
<div class="tier-amount">$5</div>
<div class="tier-freq">/month</div>
<div class="tier-desc">A small gesture</div>
</div>
<div class="tier-card suggested">
<div class="tier-amount">$15</div>
<div class="tier-freq">/month</div>
<div class="tier-desc">Sustaining</div>
<div class="tier-badge">suggested</div>
</div>
<div class="tier-card">
<div class="tier-amount">$30</div>
<div class="tier-freq">/month</div>
<div class="tier-desc">Supporting</div>
</div>
<div class="tier-card">
<div class="tier-amount">$50</div>
<div class="tier-freq">/month</div>
<div class="tier-desc">Solidarity</div>
</div>
</div>
<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 }}
<h2>Pay what you can</h2>
<ul class="tier-list">
<li><span class="tier-amt">$0</span> I need support right now</li>
<li><span class="tier-amt">$5</span> I can contribute</li>
<li>
<span class="tier-amt">$15</span> I can sustain the community
(suggested)
</li>
<li><span class="tier-amt">$30</span> I can support others too</li>
<li>
<span class="tier-amt">$50</span> I want to sponsor multiple
members
</li>
</ul>
<p class="solidarity-note">
Pay what you can. If you can pay more, you're making room for
someone who can't.
</p>
<p class="circle-not-sure">
Not sure where you fit? Start with Community. You can always move
later.
</p>
</div>
<form @submit.prevent="handleSubmit">
<div class="form-grid">
<div class="form-group">
<label class="form-label" for="join-name">Full Name</label>
<input
id="join-name"
v-model="form.name"
class="form-input"
type="text"
placeholder="Your name"
required
/>
</div>
<div class="form-group">
<label class="form-label" for="join-email">Email Address</label>
<input
id="join-email"
v-model="form.email"
class="form-input"
type="email"
placeholder="you@example.com"
required
/>
</div>
<div class="form-group full-width">
<label class="form-label">Circle</label>
<div class="circle-radios">
<div class="circle-radio community">
<input
id="circle-community"
v-model="form.circle"
type="radio"
name="circle"
value="community"
/>
<label for="circle-community">
<span class="circle-label-name" style="color: var(--c-community);">Community</span>
<span class="circle-label-desc">Exploring</span>
</label>
</div>
<div class="circle-radio founder">
<input
id="circle-founder"
v-model="form.circle"
type="radio"
name="circle"
value="founder"
/>
<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>
<!-- Right: Become a member -->
<div class="join-col">
<h2>Become a member</h2>
<p class="form-intro">
You'll get a magic link to confirm your email. No passwords.
</p>
<!-- Error Message -->
<div v-if="errorMessage" class="error-box">
{{ errorMessage }}
</div>
<form @submit.prevent="handleSubmit">
<div class="form-stack">
<div class="form-group">
<label class="form-label" for="join-name">Full Name</label>
<input
id="join-name"
v-model="form.name"
class="form-input"
type="text"
placeholder="Your name"
required
/>
</div>
<div class="form-group">
<label class="form-label" for="join-email">Email Address</label>
<input
id="join-email"
v-model="form.email"
class="form-input"
type="email"
placeholder="you@example.com"
required
/>
</div>
<div class="form-group">
<label class="form-label">Circle</label>
<div class="circle-radios">
<div class="circle-radio community">
<input
id="circle-community"
v-model="form.circle"
type="radio"
name="circle"
value="community"
/>
<label for="circle-community">
<span
class="circle-label-name"
style="color: var(--c-community)"
>Community</span
>
<span class="circle-label-desc">Exploring</span>
</label>
</div>
<div class="circle-radio founder">
<input
id="circle-founder"
v-model="form.circle"
type="radio"
name="circle"
value="founder"
/>
<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 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 class="form-group full-width">
<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 -- Access is a right</option>
<option value="5">$5/mo -- A small gesture</option>
<option value="15">$15/mo -- Sustaining (suggested)</option>
<option value="30">$30/mo -- Supporting</option>
<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>
<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 -->
<div v-if="currentStep === 2" class="form-section">
<h2>Payment Information</h2>
<p class="form-intro">
You're signing up for the {{ selectedTier.label }} plan --
${{ selectedTier.amount }} CAD / month
You're signing up for the {{ selectedTier.label }} plan -- ${{
selectedTier.amount
}}
CAD / month
</p>
<!-- Error Message -->
@ -230,15 +302,14 @@
</div>
<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>
<div class="button-row" style="margin-top: 24px;">
<button
class="btn"
:disabled="isSubmitting"
@click="goBack"
>
<div class="button-row" style="margin-top: 24px">
<button class="btn" :disabled="isSubmitting" @click="goBack">
Back
</button>
<button
@ -261,7 +332,9 @@
</div>
<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">
<div class="details-row">
<dt>Name</dt>
@ -286,17 +359,25 @@
</dl>
</DashedBox>
<p class="form-note" style="margin-top: 20px;">
We've sent a confirmation email to {{ form.email }} with your membership details.
<p class="form-note" style="margin-top: 20px">
We've sent a confirmation email to {{ form.email }} with your
membership details.
</p>
<DashedBox :hoverable="false" style="margin-top: 16px;">
<p class="redirect-note">You will be automatically redirected to your dashboard in a few seconds...</p>
<DashedBox :hoverable="false" style="margin-top: 16px">
<p class="redirect-note">
You will be automatically redirected to your dashboard in a few
seconds...
</p>
</DashedBox>
<div class="button-row" style="margin-top: 24px;">
<NuxtLink to="/member/dashboard" class="form-submit">Go to Dashboard Now</NuxtLink>
<button class="btn" @click="resetForm">Register Another Member</button>
<div class="button-row" style="margin-top: 24px">
<NuxtLink to="/member/dashboard" class="form-submit"
>Go to Dashboard Now</NuxtLink
>
<button class="btn" @click="resetForm">
Register Another Member
</button>
</div>
</div>
</template>
@ -492,7 +573,7 @@ const createSubscription = async (cardToken = null) => {
if (response.success) {
subscriptionData.value = response.subscription;
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
await checkMemberStatus();
@ -560,7 +641,7 @@ onUnmounted(() => {
border-bottom: 1px dashed var(--border);
}
.hero h1 {
font-family: 'Brygada 1918', serif;
font-family: "Brygada 1918", serif;
font-size: 36px;
font-weight: 600;
color: var(--text-bright);
@ -623,7 +704,7 @@ onUnmounted(() => {
border-right: none;
}
.content-block h2 {
font-family: 'Brygada 1918', serif;
font-family: "Brygada 1918", serif;
font-size: 18px;
font-weight: 500;
color: var(--text-bright);
@ -641,13 +722,33 @@ onUnmounted(() => {
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-section {
padding: 32px;
border-bottom: 1px dashed var(--border);
}
.full-section h2 {
font-family: 'Brygada 1918', serif;
font-family: "Brygada 1918", serif;
font-size: 20px;
font-weight: 500;
color: var(--text-bright);
@ -660,65 +761,32 @@ onUnmounted(() => {
margin-bottom: 20px;
}
/* ---- TIER CARDS ---- */
.tier-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
/* ---- TIER LIST (matches about page) ---- */
.tier-list {
list-style: none;
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;
margin: 16px 0;
}
.tier-card {
border: 1px dashed var(--border);
padding: 20px 16px;
text-align: center;
transition: border-color 0.2s;
.tier-list li:last-child {
border-bottom: none;
}
.tier-card:hover {
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;
.tier-amt {
color: var(--text-bright);
margin-bottom: 2px;
}
.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;
font-weight: 600;
min-width: 36px;
}
.solidarity-note {
font-size: 12px;
color: var(--text-dim);
line-height: 1.65;
margin-top: 16px;
max-width: 560px;
}
/* ---- FORM SECTION ---- */
@ -727,7 +795,7 @@ onUnmounted(() => {
border-bottom: 1px dashed var(--border);
}
.form-section h2 {
font-family: 'Brygada 1918', serif;
font-family: "Brygada 1918", serif;
font-size: 20px;
font-weight: 500;
color: var(--text-bright);
@ -739,9 +807,9 @@ onUnmounted(() => {
margin-bottom: 24px;
line-height: 1.65;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
.form-stack {
display: flex;
flex-direction: column;
gap: 20px;
max-width: 600px;
}
@ -750,9 +818,6 @@ onUnmounted(() => {
flex-direction: column;
gap: 6px;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-label {
font-size: 10px;
letter-spacing: 0.1em;
@ -763,7 +828,7 @@ onUnmounted(() => {
background: var(--surface);
border: 1px dashed var(--border);
color: var(--text-bright);
font-family: 'Commit Mono', monospace;
font-family: "Commit Mono", monospace;
font-size: 13px;
padding: 10px 14px;
transition: border-color 0.2s;
@ -835,7 +900,7 @@ onUnmounted(() => {
background: var(--surface);
border: 1px dashed var(--border);
color: var(--text-bright);
font-family: 'Commit Mono', monospace;
font-family: "Commit Mono", monospace;
font-size: 13px;
padding: 10px 14px;
transition: border-color 0.2s;
@ -861,7 +926,7 @@ onUnmounted(() => {
display: inline-block;
background: var(--parch);
color: var(--parch-accent);
font-family: 'Commit Mono', monospace;
font-family: "Commit Mono", monospace;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
@ -965,7 +1030,7 @@ onUnmounted(() => {
margin-bottom: 8px;
}
.info-value {
font-family: 'Brygada 1918', serif;
font-family: "Brygada 1918", serif;
font-size: 18px;
font-weight: 500;
color: var(--text-bright);
@ -989,14 +1054,12 @@ onUnmounted(() => {
.content-block:last-child {
border-bottom: none;
}
.tier-row {
grid-template-columns: repeat(3, 1fr);
}
.form-grid {
.join-two-col {
grid-template-columns: 1fr;
}
.form-group.full-width {
grid-column: auto;
.join-col:first-child {
border-right: none;
border-bottom: 1px dashed var(--border);
}
.circle-radios {
grid-template-columns: 1fr;
@ -1020,9 +1083,6 @@ onUnmounted(() => {
}
@media (max-width: 480px) {
.tier-row {
grid-template-columns: repeat(2, 1fr);
}
.button-row {
flex-direction: column;
align-items: stretch;

View file

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

View file

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

View file

@ -32,178 +32,178 @@
<!-- Member Status Banner -->
<MemberStatusBanner />
<!-- Welcome Header -->
<PageHeader :title="`Welcome back, ${memberData?.name || ''}`">
<div class="dashboard-meta">
<CircleBadge :circle="memberData?.circle || 'community'" />
<span>${{ memberData?.contributionTier }} CAD/mo</span>
<!-- Welcome Header -->
<PageHeader :title="`Welcome back, ${memberData?.name || ''}`">
<div class="dashboard-meta">
<CircleBadge :circle="memberData?.circle || 'community'" />
<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>
</PageHeader>
<!-- Upcoming Events + Quick Actions -->
<div class="content-row">
<div class="content-block">
<div class="section-label">Your Upcoming Events</div>
<div v-else-if="registeredEvents.length" class="event-list">
<NuxtLink
v-for="evt in registeredEvents"
: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">
<div class="spinner spinner-sm" />
</div>
<!-- Calendar subscription -->
<button class="calendar-btn" @click="copyCalendarLink">
{{
calendarLinkCopied
? "Link copied!"
: "Subscribe to calendar"
}}
</button>
</div>
<div v-else-if="registeredEvents.length" class="event-list">
<NuxtLink
v-for="evt in registeredEvents"
:key="evt._id"
:to="`/events/${evt.slug || evt._id}`"
class="event-item"
<div v-else class="empty-state">
<p>You haven't registered for any upcoming events</p>
</div>
<NuxtLink to="/events" class="section-link"
>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">{{
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"
}}
&times;
</button>
</div>
<div v-else class="empty-state">
<p>You haven't registered for any upcoming events</p>
</div>
<NuxtLink to="/events" class="section-link"
>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"
>
&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>
<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>
<!-- 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/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 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/account" class="quick-action">
Manage account<span class="arrow">&rarr;</span>
</NuxtLink>
</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>
</template>
@ -226,7 +226,7 @@ const { trackGoal, isComplete: onboardingComplete } = useOnboarding();
const handleWikiClick = () => {
if (!onboardingComplete.value) {
trackGoal('wikiClicked');
trackGoal("wikiClicked");
}
};

View file

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

View file

@ -3,7 +3,7 @@
<!-- Page Header -->
<PageHeader
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 -->
@ -178,11 +178,11 @@
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">
No Event Series Available
No series right now
</h3>
<p class="text-[--ui-text-muted] max-w-md mx-auto">
We're currently planning exciting event series. Check back soon for
multi-event learning journeys!
Check back later or browse
<NuxtLink to="/events" class="text-primary">upcoming events</NuxtLink>.
</p>
</div>
</UContainer>
@ -198,7 +198,7 @@ useHead({
{
name: "description",
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.",
},
],
});