Member/Ecology revamp.
This commit is contained in:
parent
fc7ec52574
commit
59d6e97787
31 changed files with 1763 additions and 1010 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -33,3 +33,4 @@ e2e/.auth/
|
||||||
|
|
||||||
# Worktrees
|
# Worktrees
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
.claude/worktrees/
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
12
app/middleware/members-auth.js
Normal file
12
app/middleware/members-auth.js
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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 — 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 →</a
|
>babyghosts.org →</a
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">×</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">×</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">×</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">×</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;
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 → <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;
|
||||||
|
|
|
||||||
|
|
@ -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. $0–50/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 →</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 →</a>
|
||||||
</p>
|
</p>
|
||||||
<p><a href="/wiki">Read more in the wiki →</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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 →</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"
|
|
||||||
}}
|
|
||||||
</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 →</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"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</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">→</span>
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink to="/member/profile" class="quick-action">
|
|
||||||
Update your profile<span class="arrow">→</span>
|
|
||||||
</NuxtLink>
|
|
||||||
<a
|
|
||||||
href="https://wiki.ghostguild.org"
|
|
||||||
target="_blank"
|
|
||||||
class="quick-action"
|
|
||||||
@click="handleWikiClick"
|
|
||||||
>
|
|
||||||
Browse the wiki<span class="arrow">→</span>
|
|
||||||
</a>
|
|
||||||
<NuxtLink to="/members" class="quick-action">
|
|
||||||
Browse members<span class="arrow">→</span>
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink to="/member/profile#account" class="quick-action">
|
|
||||||
Manage account<span class="arrow">→</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">→</span>
|
||||||
</span>
|
</NuxtLink>
|
||||||
</div>
|
<NuxtLink to="/member/profile" class="quick-action">
|
||||||
<div class="membership-row">
|
Update your profile<span class="arrow">→</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">→</span>
|
||||||
<span :class="isActive ? 'status-active' : ''">
|
</a>
|
||||||
{{ isActive ? "Active" : statusConfig.label }}
|
<NuxtLink to="/members" class="quick-action">
|
||||||
</span>
|
Browse members<span class="arrow">→</span>
|
||||||
</span>
|
</NuxtLink>
|
||||||
</div>
|
<NuxtLink to="/member/account" class="quick-action">
|
||||||
<div v-if="memberData?.createdAt" class="membership-row">
|
Manage account<span class="arrow">→</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 →
|
|
||||||
</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 →
|
|
||||||
</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 →
|
||||||
|
</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 →
|
||||||
|
</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");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
34
server/api/admin/wiki/batch-visibility.post.js
Normal file
34
server/api/admin/wiki/batch-visibility.post.js
Normal 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 }
|
||||||
|
})
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
20
server/api/wiki/recent.get.js
Normal file
20
server/api/wiki/recent.get.js
Normal 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
|
||||||
|
})
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
26
server/plugins/wiki-sync.js
Normal file
26
server/plugins/wiki-sync.js
Normal 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)
|
||||||
|
})
|
||||||
|
|
@ -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]))
|
||||||
|
}
|
||||||
|
|
|
||||||
72
server/utils/syncWikiArticles.js
Normal file
72
server/utils/syncWikiArticles.js
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue