UI/UX tweaks and improvements.
This commit is contained in:
parent
4daec9b624
commit
418d3cc402
32 changed files with 2725 additions and 1201 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -18,7 +18,7 @@ logs
|
|||
.fleet
|
||||
.idea
|
||||
/docs/
|
||||
*.md/
|
||||
/*.md
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
|
|
|
|||
99
CLAUDE.md
99
CLAUDE.md
|
|
@ -1,99 +0,0 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Ghost Guild is a membership community platform for game developers exploring cooperative business models. Built with Nuxt 4, Vue 3, MongoDB, and Nuxt UI 4.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev server at http://localhost:3000
|
||||
npm run build # Production build
|
||||
npm run preview # Preview production build
|
||||
npm run test:run # Vitest single run (pre-push hook)
|
||||
npm run test:e2e # Playwright E2E (needs dev server + MongoDB)
|
||||
npm run test:a11y # Accessibility scans
|
||||
npm run test:all # Vitest + Playwright
|
||||
```
|
||||
|
||||
**Dev helpers:** `GET /api/dev/test-login` — creates a test admin user and sets auth cookie (dev only, blocked in production). Navigate to this URL to access admin pages during development.
|
||||
|
||||
**Testing:** Vitest for unit/handler tests (`tests/`), Playwright for E2E (`e2e/`). Husky pre-push hook runs Vitest. See `TESTING.md` for details.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Stack
|
||||
|
||||
- **Framework:** Nuxt 4 (Vue 3 + Nitro server)
|
||||
- **UI:** Nuxt UI 4 (`@nuxt/ui@^4`) with Tailwind CSS
|
||||
- **Database:** MongoDB via Mongoose
|
||||
- **Auth:** JWT magic link (email-only, no passwords)
|
||||
- **Payments:** Helcim (recurring subscriptions + ticket sales)
|
||||
- **Email:** Resend
|
||||
- **Slack:** `@slack/web-api` for member invitations and notifications
|
||||
- **Images:** Cloudinary
|
||||
- **Analytics:** Plausible (`ghostguild.org`)
|
||||
|
||||
### Key Directories
|
||||
|
||||
- `app/composables/` — State management via `useState()` (no Pinia/Vuex). Key composables: `useAuth`, `useHelcim`, `useMemberPayment`, `useMemberStatus`
|
||||
- `app/config/` — Circle definitions (`circles.js`) and contribution tiers (`contributions.js`) used across frontend and forms
|
||||
- `app/middleware/` — Route guards: `auth.js` (member pages), `admin.js` (admin pages), `coming-soon.global.js` (launch gate)
|
||||
- `app/layouts/` — `default` (sidebar, member/public), `admin` (sidebar, admin pages), `landing`, `coming-soon`
|
||||
- `server/api/` — Nitro API routes organized by feature: `auth/`, `events/`, `members/`, `helcim/`, `series/`, `updates/`, `admin/`, `slack/`, `dev/` (dev-only helpers)
|
||||
- `server/models/` — Mongoose schemas: `Member`, `Event`, `Series`, `Update`
|
||||
- `server/utils/` — Service integrations: `mongoose.js`, `helcim.js`, `resend.js`, `slack.ts`, `tickets.js`
|
||||
|
||||
### Domain Model
|
||||
|
||||
Three membership **circles**: Community, Founder, Practitioner — each with different access and context. Five **contribution tiers**: $0, $5, $15, $30, $50/month via Helcim subscriptions.
|
||||
|
||||
Member statuses: `pending_payment`, `active`, `suspended`, `cancelled`.
|
||||
|
||||
Events support ticketing with circle-specific pricing overrides and can be grouped into Series with bundled passes.
|
||||
|
||||
### Design System (Zine Direction)
|
||||
|
||||
- **Palette:** CSS custom properties in `:root` / `.dark` blocks in `app/assets/css/main.css` — `--bg` (cream/#f4efe4), `--surface`, `--border`, `--candle` (gold accent), `--ember` (rust accent), `--text`, `--text-bright`, `--text-dim`, `--text-faint`, `--parch` (inverted blocks), `--c-community`, `--c-founder`, `--c-practitioner`
|
||||
- **Typography:** Brygada 1918 (serif, display/headings) + Commit Mono (monospace, body/UI/everything structural) — loaded via Google Fonts in `nuxt.config.ts`
|
||||
- **Theme:** `primary: amber`, `neutral: stone` — configured in `app/app.config.ts`. Tailwind `@theme` maps `--font-sans` and `--font-mono` to Commit Mono, `--font-display` to Brygada 1918
|
||||
- **Key classes:** `.btn` / `.btn-primary` / `.btn-danger` (buttons), `.field` (form groups), `.badge` (circle badges), `.section-label` (10px uppercase headers), `.dashed-box` (bordered containers), `.section-divider`
|
||||
- **Visual language:** Dashed borders (1px dashed), cream backgrounds, no rounded corners, text-forward density, minimal decoration
|
||||
- **Color mode:** `@nuxtjs/color-mode` with preference `system`, fallback `light`. Dark mode via `.dark` class on `<html>`
|
||||
- **Layouts:** `default` (sidebar + main, member/public pages), `admin` (sidebar + main, admin pages), `landing` (horizontal nav, unused)
|
||||
|
||||
### Environment
|
||||
|
||||
Copy `.env.example` to `.env`. Required: `MONGODB_URI`, `JWT_SECRET`, `RESEND_API_KEY`, `HELCIM_API_TOKEN`, `SLACK_BOT_TOKEN`. Public vars are prefixed `NUXT_PUBLIC_`. The `NUXT_PUBLIC_COMING_SOON` flag gates access behind a launch page.
|
||||
|
||||
## Conventions
|
||||
|
||||
- All frontend code is plain JavaScript (not TypeScript), using Vue 3 Composition API
|
||||
- Server utilities auto-imported by Nitro — no explicit imports needed in API routes
|
||||
- Use `USwitch` (not `UToggle`) — this is the correct Nuxt UI 3+ component name
|
||||
- No fallback/placeholder data — always use real data
|
||||
- Follow Nuxt 4 file-based routing conventions for route naming
|
||||
- Always check Nuxt UI 4 latest documentation on the web when implementing UI components
|
||||
- Auth API responses (`/api/auth/status`, `/api/auth/member`) must include `status` in the returned member object — `useMemberStatus` defaults to `PENDING_PAYMENT` if missing
|
||||
- Helcim payment testing requires ngrok: `npx nuxi dev --https` then `ngrok http https://localhost:3000` — Helcim blocks localhost origins
|
||||
- The `/api/helcim/initialize-payment` endpoint skips auth for `event_ticket` type payments (public users can buy tickets)
|
||||
|
||||
## Product Spec
|
||||
|
||||
The sections below describe planned and in-progress features for reference.
|
||||
|
||||
### Member Features
|
||||
- Profiles with privacy controls (public/members-only/private per field)
|
||||
- Member updates/mini blog with rich text and images
|
||||
- Peer support system with Cal.com integration for 1:1 scheduling
|
||||
|
||||
### Events System
|
||||
- RSVP with capacity limits and waitlist management
|
||||
- Calendar export (.ics), ticketing, series passes
|
||||
- Member-proposed events with interest threshold
|
||||
|
||||
### Resources (Planned)
|
||||
- Learning paths by circle, templates and tools, case studies
|
||||
- Tag by circle relevance, download tracking, version control
|
||||
|
|
@ -1,8 +1,16 @@
|
|||
/*
|
||||
* Font declarations for Ghost Guild — Zine Direction
|
||||
* Self-hosted font declarations for Ghost Guild — Zine Direction
|
||||
*
|
||||
* Brygada 1918: Display/heading serif (Google Fonts, variable 400-700, italic)
|
||||
* Commit Mono: Body/UI monospace (Google Fonts)
|
||||
* Brygada 1918: Display/heading serif
|
||||
* Commit Mono: Body/UI monospace
|
||||
*
|
||||
* Loaded via Google Fonts link in nuxt.config.ts head.
|
||||
* Fonts are bundled locally via Fontsource.
|
||||
*/
|
||||
|
||||
@import "@fontsource-variable/brygada-1918/wght.css";
|
||||
@import "@fontsource-variable/brygada-1918/wght-italic.css";
|
||||
|
||||
@import "@fontsource/commit-mono/400.css";
|
||||
@import "@fontsource/commit-mono/500.css";
|
||||
@import "@fontsource/commit-mono/600.css";
|
||||
@import "@fontsource/commit-mono/700.css";
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
:root {
|
||||
--bg: #f4efe4;
|
||||
--input-bg: #faf8f2;
|
||||
--surface: #e8dfc8;
|
||||
--surface-hover: #e0d6bc;
|
||||
--border: #b8a880;
|
||||
|
|
@ -36,10 +37,12 @@
|
|||
--c-practitioner: #2a4650;
|
||||
--green: #4a6a38;
|
||||
--green-bg: rgba(74, 106, 56, 0.08);
|
||||
--ember-bg: rgba(138, 68, 32, 0.1);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--bg: #131210;
|
||||
--input-bg: #1c1a17;
|
||||
--surface: #1a1815;
|
||||
--surface-hover: #252220;
|
||||
--border: #2a2520;
|
||||
|
|
@ -59,16 +62,17 @@
|
|||
--c-community: #a06850;
|
||||
--c-founder: #c06030;
|
||||
--c-practitioner: #4a7080;
|
||||
--ember-bg: rgba(192, 96, 48, 0.14);
|
||||
}
|
||||
|
||||
/* ---- TAILWIND @THEME MAPPING ---- */
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Commit Mono', monospace;
|
||||
--font-body: 'Commit Mono', monospace;
|
||||
--font-mono: 'Commit Mono', monospace;
|
||||
--font-display: 'Brygada 1918', serif;
|
||||
--font-serif: 'Brygada 1918', serif;
|
||||
--font-sans: "Commit Mono", monospace;
|
||||
--font-body: "Commit Mono", monospace;
|
||||
--font-mono: "Commit Mono", monospace;
|
||||
--font-display: "Brygada 1918", serif;
|
||||
--font-serif: "Brygada 1918", serif;
|
||||
|
||||
/* Map primary to candle for Nuxt UI components */
|
||||
--color-primary-500: var(--candle);
|
||||
|
|
@ -81,14 +85,30 @@
|
|||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--candle); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
/* ---- NOISE TEXTURE OVERLAY ---- */
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
background: url("~/assets/images/noise.webp") repeat;
|
||||
opacity: 0.025;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--candle);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ---- SECTION LABELS ---- */
|
||||
.section-label {
|
||||
|
|
@ -108,14 +128,26 @@ a:hover { text-decoration: underline; }
|
|||
padding: 2px 8px;
|
||||
border: 1px dashed;
|
||||
}
|
||||
.badge.community { color: var(--c-community); border-color: rgba(122, 72, 56, 0.35); }
|
||||
.badge.founder { color: var(--c-founder); border-color: rgba(138, 68, 32, 0.35); }
|
||||
.badge.practitioner { color: var(--c-practitioner); border-color: rgba(42, 70, 80, 0.35); }
|
||||
.badge.all { color: var(--text-dim); border-color: var(--border); }
|
||||
.badge.community {
|
||||
color: var(--c-community);
|
||||
border-color: rgba(122, 72, 56, 0.35);
|
||||
}
|
||||
.badge.founder {
|
||||
color: var(--c-founder);
|
||||
border-color: rgba(138, 68, 32, 0.35);
|
||||
}
|
||||
.badge.practitioner {
|
||||
color: var(--c-practitioner);
|
||||
border-color: rgba(42, 70, 80, 0.35);
|
||||
}
|
||||
.badge.all {
|
||||
color: var(--text-dim);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
/* ---- BUTTONS ---- */
|
||||
.btn {
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 12px;
|
||||
padding: 7px 18px;
|
||||
border: 1px dashed var(--border);
|
||||
|
|
@ -125,14 +157,20 @@ a:hover { text-decoration: underline; }
|
|||
letter-spacing: 0.04em;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn:hover { background: var(--surface-hover); border-color: var(--border-d); }
|
||||
.btn:hover {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--border-d);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--candle);
|
||||
color: var(--bg);
|
||||
border-color: var(--candle);
|
||||
border-style: solid;
|
||||
}
|
||||
.btn-primary:hover { background: var(--candle-dim); border-color: var(--candle-dim); }
|
||||
.btn-primary:hover {
|
||||
background: var(--candle-dim);
|
||||
border-color: var(--candle-dim);
|
||||
}
|
||||
.btn-danger {
|
||||
color: var(--ember);
|
||||
border-color: var(--ember);
|
||||
|
|
@ -144,7 +182,9 @@ a:hover { text-decoration: underline; }
|
|||
}
|
||||
|
||||
/* ---- FORM FIELDS ---- */
|
||||
.field { margin-bottom: 12px; }
|
||||
.field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.field label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
|
|
@ -153,17 +193,21 @@ a:hover { text-decoration: underline; }
|
|||
margin-bottom: 3px;
|
||||
display: block;
|
||||
}
|
||||
.field input, .field select, .field textarea {
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 13px;
|
||||
color: var(--text-bright);
|
||||
background: var(--bg);
|
||||
background: var(--input-bg);
|
||||
border: 1px dashed var(--border);
|
||||
outline: none;
|
||||
}
|
||||
.field input:focus, .field select:focus, .field textarea:focus {
|
||||
.field input:focus,
|
||||
.field select:focus,
|
||||
.field textarea:focus {
|
||||
border-color: var(--candle);
|
||||
border-style: solid;
|
||||
}
|
||||
|
|
@ -174,8 +218,25 @@ a:hover { text-decoration: underline; }
|
|||
padding: 20px 24px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.dashed-box:hover { border-color: var(--candle-faint); }
|
||||
.dashed-box.no-hover:hover { border-color: var(--border); }
|
||||
.dashed-box:hover {
|
||||
border-color: var(--candle-faint);
|
||||
}
|
||||
.dashed-box.no-hover:hover {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
/* ---- SEGMENTED CONTROL (flush dashed-border groups) ---- */
|
||||
/* Negative-margin overlap: every item keeps all 4 borders,
|
||||
siblings overlap by 1px, active item paints on top via z-index. */
|
||||
.segmented {
|
||||
display: flex;
|
||||
}
|
||||
.segmented > * {
|
||||
position: relative;
|
||||
}
|
||||
.segmented > * + * {
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
/* ---- SECTION DIVIDERS ---- */
|
||||
.section-divider {
|
||||
|
|
|
|||
BIN
app/assets/images/noise.webp
Normal file
BIN
app/assets/images/noise.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
|
|
@ -17,7 +17,13 @@
|
|||
:to="item.path"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="handleNavigate"
|
||||
>{{ item.label }}</NuxtLink>
|
||||
>{{ item.label }}</NuxtLink
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="sign-out" @click.prevent="handleLogout"
|
||||
>Sign out</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
|
@ -28,18 +34,8 @@
|
|||
:to="item.path"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="handleNavigate"
|
||||
>{{ item.label }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="sidebar-section">Community</div>
|
||||
<ul class="sidebar-nav">
|
||||
<li v-for="item in communityItems" :key="item.path">
|
||||
<NuxtLink
|
||||
:to="item.path"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="handleNavigate"
|
||||
>{{ item.label }}</NuxtLink>
|
||||
>{{ item.label }}</NuxtLink
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
|
@ -53,7 +49,8 @@
|
|||
:to="item.path"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="handleNavigate"
|
||||
>{{ item.label }}</NuxtLink>
|
||||
>{{ item.label }}</NuxtLink
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
|
@ -64,7 +61,8 @@
|
|||
:to="item.path"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="handleNavigate"
|
||||
>{{ item.label }}</NuxtLink>
|
||||
>{{ item.label }}</NuxtLink
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
|
@ -78,7 +76,8 @@
|
|||
:to="item.path"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="handleNavigate"
|
||||
>{{ item.label }}</NuxtLink>
|
||||
>{{ item.label }}</NuxtLink
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
|
@ -89,7 +88,8 @@
|
|||
:to="item.path"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
@click="handleNavigate"
|
||||
>{{ item.label }}</NuxtLink>
|
||||
>{{ item.label }}</NuxtLink
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
|
@ -99,29 +99,18 @@
|
|||
<!-- Meta at bottom -->
|
||||
<div class="sidebar-meta">
|
||||
<ClientOnly>
|
||||
<template v-if="isAuthenticated">
|
||||
<span class="member-name">{{ memberData?.name || 'Member' }}</span><br>
|
||||
<span
|
||||
v-if="memberData?.circle"
|
||||
class="member-circle"
|
||||
:style="{ color: `var(--c-${memberData.circle})` }"
|
||||
>{{ memberData.circle }}</span>
|
||||
<br v-if="memberData?.circle">
|
||||
<a href="#" @click.prevent="handleLogout">Sign out</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
Part of <a href="https://babyghosts.fund" target="_blank">Baby Ghosts</a><br>
|
||||
A Canadian nonprofit<br>
|
||||
<a href="#" @click.prevent="openLogin">Sign in</a>
|
||||
</template>
|
||||
|
||||
Part of
|
||||
<a href="https://babyghosts.fund" target="_blank">Baby Ghosts</a><br />
|
||||
A Canadian nonprofit
|
||||
<template #fallback>
|
||||
Part of <a href="https://babyghosts.fund" target="_blank">Baby Ghosts</a><br>
|
||||
A Canadian nonprofit<br>
|
||||
<a href="#" @click.prevent="openLogin">Sign in</a>
|
||||
Part of
|
||||
<a href="https://babyghosts.fund" target="_blank">Baby Ghosts</a
|
||||
><br />
|
||||
A Canadian nonprofit
|
||||
</template>
|
||||
</ClientOnly>
|
||||
<ClientOnly>
|
||||
<DevLoginPanel v-if="isDev" />
|
||||
<ColorModeToggle />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
|
@ -134,68 +123,59 @@ const props = defineProps({
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const emit = defineEmits(['navigate'])
|
||||
const emit = defineEmits(["navigate"]);
|
||||
|
||||
const route = useRoute()
|
||||
const { isAuthenticated, logout, memberData } = useAuth()
|
||||
const { openLoginModal } = useLoginModal()
|
||||
const route = useRoute();
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
const isDev = import.meta.dev;
|
||||
|
||||
const handleNavigate = () => {
|
||||
if (props.isMobile) {
|
||||
emit('navigate')
|
||||
emit("navigate");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
handleNavigate()
|
||||
}
|
||||
|
||||
const openLogin = () => {
|
||||
openLoginModal()
|
||||
handleNavigate()
|
||||
}
|
||||
await logout();
|
||||
handleNavigate();
|
||||
navigateTo("/");
|
||||
};
|
||||
|
||||
const isActive = (path) => {
|
||||
if (path === '/') return route.path === '/'
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
if (path === "/") return route.path === "/";
|
||||
return route.path.startsWith(path);
|
||||
};
|
||||
|
||||
// Public nav items
|
||||
const publicItems = [
|
||||
{ label: 'Home', path: '/' },
|
||||
{ label: 'About', path: '/about' },
|
||||
{ label: 'Events', path: '/events' },
|
||||
{ label: 'Members', path: '/members' },
|
||||
{ label: 'Wiki', path: '/wiki' },
|
||||
]
|
||||
{ label: "Home", path: "/" },
|
||||
{ label: "About", path: "/about" },
|
||||
{ label: "Events", path: "/events" },
|
||||
{ label: "Members", path: "/members" },
|
||||
{ label: "Wiki", path: "https://wiki.ghostguild.org" },
|
||||
];
|
||||
|
||||
const joinItems = [
|
||||
{ label: 'Become a member', path: '/join' },
|
||||
{ label: 'Propose an event', path: '/events' },
|
||||
]
|
||||
{ label: "Become a member", path: "/join" },
|
||||
{ label: "Propose an event", path: "/events" },
|
||||
];
|
||||
|
||||
// Logged-in nav items
|
||||
const youItems = [
|
||||
{ label: 'Dashboard', path: '/member/dashboard' },
|
||||
{ label: 'Profile', path: '/member/profile' },
|
||||
{ label: 'Account', path: '/member/account' },
|
||||
{ label: 'My Updates', path: '/member/my-updates' },
|
||||
]
|
||||
{ label: "Dashboard", path: "/member/dashboard" },
|
||||
{ label: "Profile", path: "/member/profile" },
|
||||
{ label: "Account", path: "/member/account" },
|
||||
{ label: "My Updates", path: "/member/my-updates" },
|
||||
];
|
||||
|
||||
const exploreItems = [
|
||||
{ label: 'Events', path: '/events' },
|
||||
{ label: 'Members', path: '/members' },
|
||||
{ label: 'Wiki', path: '/wiki' },
|
||||
{ label: 'About', path: '/about' },
|
||||
]
|
||||
|
||||
const communityItems = [
|
||||
{ label: 'Peer Support', path: '/members' },
|
||||
{ label: 'Propose an Event', path: '/events' },
|
||||
]
|
||||
{ label: "Events", path: "/events" },
|
||||
{ label: "Members", path: "/members" },
|
||||
{ label: "Wiki", path: "/wiki" },
|
||||
{ label: "About", path: "/about" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -221,12 +201,14 @@ const communityItems = [
|
|||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: block;
|
||||
font-family: 'Brygada 1918', serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--candle);
|
||||
padding: 24px 24px 16px;
|
||||
padding: 0 24px;
|
||||
height: 53px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
|
@ -237,6 +219,7 @@ const communityItems = [
|
|||
.sidebar-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
|
|
@ -267,6 +250,11 @@ const communityItems = [
|
|||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar-nav a.sign-out {
|
||||
color: var(--text-faint);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.sidebar-nav a:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
|
|
@ -290,15 +278,4 @@ const communityItems = [
|
|||
.sidebar-meta a {
|
||||
color: var(--candle-dim);
|
||||
}
|
||||
|
||||
.member-name {
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.member-circle {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,24 @@
|
|||
<template>
|
||||
<div class="color-mode-toggle">
|
||||
<div class="color-mode-toggle segmented">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:class="{ active: colorMode.preference === option.value }"
|
||||
@click="colorMode.preference = option.value"
|
||||
>{{ option.label }}</button>
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const colorMode = useColorMode()
|
||||
const colorMode = useColorMode();
|
||||
|
||||
const options = [
|
||||
{ label: 'Light', value: 'light' },
|
||||
{ label: 'System', value: 'system' },
|
||||
{ label: 'Dark', value: 'dark' },
|
||||
]
|
||||
{ label: "Light", value: "light" },
|
||||
{ label: "System", value: "system" },
|
||||
{ label: "Dark", value: "dark" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -28,7 +30,7 @@ const options = [
|
|||
.color-mode-toggle button {
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
background: transparent;
|
||||
|
|
@ -36,10 +38,12 @@ const options = [
|
|||
border: 1px dashed var(--border);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Overlap adjacent borders so dashed lines collapse into one */
|
||||
.color-mode-toggle button + button {
|
||||
border-left: none;
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
.color-mode-toggle button:hover {
|
||||
|
|
@ -51,13 +55,6 @@ const options = [
|
|||
border-color: var(--candle);
|
||||
border-style: solid;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* When active button is adjacent to dashed, restore left border */
|
||||
.color-mode-toggle button.active + button {
|
||||
border-left: 1px dashed var(--border);
|
||||
}
|
||||
.color-mode-toggle button:has(+ button.active) {
|
||||
border-right: none;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
93
app/components/DevLoginPanel.vue
Normal file
93
app/components/DevLoginPanel.vue
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<template>
|
||||
<div class="dev-login">
|
||||
<div class="dev-label">Dev Login</div>
|
||||
<div class="dev-actions">
|
||||
<div class="dev-buttons">
|
||||
<a href="/api/dev/test-login" class="dev-button">Admin</a>
|
||||
<button class="dev-button dev-logout" @click="handleLogout">Log out</button>
|
||||
</div>
|
||||
<USelectMenu
|
||||
v-model="selectedEmail"
|
||||
:items="members"
|
||||
value-key="value"
|
||||
:filter-fields="['label', 'value']"
|
||||
placeholder="Switch user..."
|
||||
:search-input="{ placeholder: 'Search members...' }"
|
||||
class="dev-select"
|
||||
size="xs"
|
||||
@update:model-value="loginAsEmail"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const selectedEmail = ref(null)
|
||||
const { logout } = useAuth()
|
||||
|
||||
const { data: members } = await useFetch('/api/dev/members', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const loginAsEmail = (email) => {
|
||||
if (email) {
|
||||
navigateTo(`/api/dev/member-login?email=${encodeURIComponent(email)}`, { external: true })
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dev-login {
|
||||
margin-top: 10px;
|
||||
padding: 8px;
|
||||
border: 1px dashed var(--ember);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dev-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ember);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dev-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dev-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dev-button {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 11px;
|
||||
background: var(--surface);
|
||||
color: var(--ember);
|
||||
border: 1px solid var(--ember);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.dev-button:hover {
|
||||
background: var(--ember);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
|
||||
.dev-select {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -6,13 +6,16 @@
|
|||
<div v-if="events?.length" class="em-rows">
|
||||
<div v-for="event in events" :key="event._id" class="em-item">
|
||||
<div class="em-inset em-item-body">
|
||||
<span class="em-date">{{ formatDate(event.date) }}</span>
|
||||
<NuxtLink :to="`/events/${event._id}`" class="em-title">{{ event.title }}</NuxtLink>
|
||||
<span class="em-date">{{ formatDate(event.startDate) }}</span>
|
||||
<NuxtLink :to="`/events/${event._id}`" class="em-title">{{
|
||||
event.title
|
||||
}}</NuxtLink>
|
||||
<span
|
||||
v-if="event.circle"
|
||||
class="em-circle"
|
||||
:style="{ color: `var(--c-${event.circle})` }"
|
||||
>{{ event.circle }}</span>
|
||||
>{{ event.circle }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -30,13 +33,13 @@
|
|||
<script setup>
|
||||
defineProps({
|
||||
events: { type: Array, default: () => [] },
|
||||
})
|
||||
});
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -1,60 +1,31 @@
|
|||
<template>
|
||||
<ClientOnly>
|
||||
<div v-if="shouldShowBanner" class="w-full">
|
||||
<div
|
||||
:class="[
|
||||
'backdrop-blur-sm border rounded-lg p-4 flex items-start gap-4',
|
||||
statusConfig.bgColor,
|
||||
statusConfig.borderColor,
|
||||
]"
|
||||
>
|
||||
<Icon
|
||||
:name="statusConfig.icon"
|
||||
:class="['w-5 h-5 flex-shrink-0 mt-0.5', statusConfig.textColor]"
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 :class="['font-semibold mb-1', statusConfig.textColor]">
|
||||
{{ statusConfig.label }}
|
||||
</h3>
|
||||
<p :class="['text-sm', statusConfig.textColor, 'opacity-90']">
|
||||
{{ bannerMessage }}
|
||||
</p>
|
||||
<div v-if="shouldShowBanner" class="status-banner">
|
||||
<div class="status-banner-inner">
|
||||
<div class="status-banner-text">
|
||||
<strong class="status-banner-label">{{ statusConfig.label }}</strong>
|
||||
<span class="status-banner-msg">{{ bannerMessage }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div v-if="nextAction" class="status-banner-actions">
|
||||
<!-- Payment button for pending payment status -->
|
||||
<UButton
|
||||
v-if="isPendingPayment && nextAction"
|
||||
:color="getButtonColor(nextAction.color)"
|
||||
size="sm"
|
||||
:loading="isProcessingPayment"
|
||||
<button
|
||||
v-if="isPendingPayment"
|
||||
:disabled="isProcessingPayment"
|
||||
class="btn btn-primary"
|
||||
@click="handleActionClick"
|
||||
class="whitespace-nowrap"
|
||||
>
|
||||
{{ isProcessingPayment ? "Processing..." : nextAction.label }}
|
||||
</UButton>
|
||||
</button>
|
||||
|
||||
<!-- Link button for other actions -->
|
||||
<NuxtLink
|
||||
v-else-if="nextAction && nextAction.link"
|
||||
v-else-if="nextAction.link"
|
||||
:to="nextAction.link"
|
||||
:class="[
|
||||
'px-4 py-2 rounded-lg font-medium text-sm whitespace-nowrap transition-all',
|
||||
getActionButtonClass(nextAction.color),
|
||||
]"
|
||||
class="btn"
|
||||
>
|
||||
{{ nextAction.label }}
|
||||
</NuxtLink>
|
||||
|
||||
<button
|
||||
v-if="dismissible"
|
||||
@click="isDismissed = true"
|
||||
class="text-guild-400 hover:text-guild-200 transition-colors"
|
||||
:aria-label="`Dismiss ${statusConfig.label} banner`"
|
||||
>
|
||||
<Icon name="heroicons:x-mark" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -62,17 +33,6 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
dismissible: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
isPendingPayment,
|
||||
isSuspended,
|
||||
|
|
@ -81,11 +41,9 @@ const {
|
|||
getNextAction,
|
||||
getBannerMessage,
|
||||
} = useMemberStatus();
|
||||
|
||||
const { completePayment, isProcessingPayment } = useMemberPayment();
|
||||
|
||||
const isDismissed = ref(false);
|
||||
|
||||
// Handle action button click
|
||||
const handleActionClick = async () => {
|
||||
if (isPendingPayment.value) {
|
||||
try {
|
||||
|
|
@ -96,33 +54,57 @@ const handleActionClick = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
// Map color names to UButton color props
|
||||
const getButtonColor = (color) => {
|
||||
const colorMap = {
|
||||
orange: "warning",
|
||||
blue: "primary",
|
||||
gray: "neutral",
|
||||
};
|
||||
return colorMap[color] || "primary";
|
||||
};
|
||||
|
||||
// Only show banner if status is not active
|
||||
const shouldShowBanner = computed(() => {
|
||||
if (isDismissed.value) return false;
|
||||
return isPendingPayment.value || isSuspended.value || isCancelled.value;
|
||||
});
|
||||
const shouldShowBanner = computed(
|
||||
() => isPendingPayment.value || isSuspended.value || isCancelled.value,
|
||||
);
|
||||
|
||||
const bannerMessage = computed(() => getBannerMessage());
|
||||
const nextAction = computed(() => getNextAction());
|
||||
|
||||
// Button styling based on color
|
||||
const getActionButtonClass = (color) => {
|
||||
const baseClass = "hover:scale-105 active:scale-95";
|
||||
const colorClasses = {
|
||||
orange: "bg-candlelight-600 text-white hover:bg-candlelight-700",
|
||||
blue: "bg-guild-600 text-white hover:bg-guild-500",
|
||||
gray: "bg-guild-700 text-guild-100 hover:bg-guild-600",
|
||||
};
|
||||
return `${baseClass} ${colorClasses[color] || colorClasses.blue}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-banner {
|
||||
width: 100%;
|
||||
background: var(--parch);
|
||||
}
|
||||
|
||||
.status-banner-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-banner-text {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-banner-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--parch-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-banner-msg {
|
||||
font-size: 12px;
|
||||
color: var(--parch-text-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.status-banner-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Ensure no border-radius leaks in from global resets or UButton */
|
||||
.status-banner .btn {
|
||||
border-radius: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,27 @@
|
|||
<template>
|
||||
<div class="priv">
|
||||
<div class="priv segmented">
|
||||
<span
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
:class="{ on: modelValue === opt.value }"
|
||||
@click="$emit('update:modelValue', opt.value)"
|
||||
>{{ opt.label }}</span>
|
||||
>{{ opt.label }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: { type: String, default: 'public' },
|
||||
})
|
||||
modelValue: { type: String, default: "public" },
|
||||
});
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
defineEmits(["update:modelValue"]);
|
||||
|
||||
const options = [
|
||||
{ label: 'Public', value: 'public' },
|
||||
{ label: 'Members', value: 'members' },
|
||||
{ label: 'Private', value: 'private' },
|
||||
]
|
||||
{ label: "Public", value: "public" },
|
||||
{ label: "Members", value: "members" },
|
||||
{ label: "Private", value: "private" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -28,7 +29,7 @@ const options = [
|
|||
display: inline-flex;
|
||||
gap: 0;
|
||||
font-size: 9px;
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-family: "Commit Mono", monospace;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
|
|
@ -44,10 +45,11 @@ const options = [
|
|||
transition: all 0.12s;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.priv span + span {
|
||||
border-left: none;
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
.priv span:hover {
|
||||
|
|
@ -59,9 +61,6 @@ const options = [
|
|||
color: var(--text-bright);
|
||||
border-color: var(--candle);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.priv span.on + span {
|
||||
border-left-color: var(--candle);
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -17,37 +17,37 @@
|
|||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: 'Add tag...' },
|
||||
})
|
||||
placeholder: { type: String, default: "Add tag..." },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const input = ref(null)
|
||||
const newTag = ref('')
|
||||
const input = ref(null);
|
||||
const newTag = ref("");
|
||||
|
||||
const focusInput = () => {
|
||||
input.value?.focus()
|
||||
}
|
||||
input.value?.focus();
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
const tag = newTag.value.trim()
|
||||
const tag = newTag.value.trim();
|
||||
if (tag && !props.modelValue.includes(tag)) {
|
||||
emit('update:modelValue', [...props.modelValue, tag])
|
||||
emit("update:modelValue", [...props.modelValue, tag]);
|
||||
}
|
||||
newTag.value = ''
|
||||
}
|
||||
newTag.value = "";
|
||||
};
|
||||
|
||||
const removeTag = (index) => {
|
||||
const tags = [...props.modelValue]
|
||||
tags.splice(index, 1)
|
||||
emit('update:modelValue', tags)
|
||||
}
|
||||
const tags = [...props.modelValue];
|
||||
tags.splice(index, 1);
|
||||
emit("update:modelValue", tags);
|
||||
};
|
||||
|
||||
const handleBackspace = () => {
|
||||
if (!newTag.value && props.modelValue.length) {
|
||||
removeTag(props.modelValue.length - 1)
|
||||
removeTag(props.modelValue.length - 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -57,7 +57,7 @@ const handleBackspace = () => {
|
|||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
background: var(--bg);
|
||||
background: var(--input-bg);
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
cursor: text;
|
||||
|
|
@ -95,7 +95,7 @@ const handleBackspace = () => {
|
|||
background: transparent;
|
||||
padding: 1px 4px;
|
||||
font-size: 11px;
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-family: "Commit Mono", monospace;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
|
|
|
|||
|
|
@ -19,16 +19,16 @@ defineProps({
|
|||
tiers: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{ amount: 0, display: '$0', label: 'Free' },
|
||||
{ amount: 5, display: '$5', label: '/month' },
|
||||
{ amount: 15, display: '$15', label: '/month' },
|
||||
{ amount: 30, display: '$30', label: '/month' },
|
||||
{ amount: 50, display: '$50', label: '/month' },
|
||||
{ amount: 0, display: "$0", label: "Free" },
|
||||
{ amount: 5, display: "$5", label: "/month" },
|
||||
{ amount: 15, display: "$15", label: "/month" },
|
||||
{ amount: 30, display: "$30", label: "/month" },
|
||||
{ amount: 50, display: "$50", label: "/month" },
|
||||
],
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
defineEmits(["update:modelValue"]);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -46,27 +46,31 @@ defineEmits(['update:modelValue'])
|
|||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Overlap adjacent borders so dashed lines collapse into one */
|
||||
.tier-option + .tier-option {
|
||||
border-left: none;
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
.tier-option:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
/* Active item paints its solid border on top of any neighbor */
|
||||
.tier-option.current {
|
||||
border-color: var(--candle);
|
||||
border-style: solid;
|
||||
background: var(--surface);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tier-amount {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-family: "Brygada 1918", serif;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +1,60 @@
|
|||
<template>
|
||||
<div class="top-strip">
|
||||
<span>
|
||||
<slot name="left">ghostguild.org{{ pagePath ? ` / ${pagePath}` : '' }}</slot>
|
||||
<slot name="left">
|
||||
<span class="breadcrumb-nav">
|
||||
<NuxtLink to="/" class="breadcrumb-link">ghostguild.org</NuxtLink>
|
||||
<template v-for="(crumb, i) in breadcrumbs" :key="i">
|
||||
<span class="breadcrumb-sep"> / </span>
|
||||
<NuxtLink :to="crumb.path" class="breadcrumb-link">{{
|
||||
crumb.label
|
||||
}}</NuxtLink>
|
||||
</template>
|
||||
</span>
|
||||
</slot>
|
||||
</span>
|
||||
<span>
|
||||
<slot name="right">
|
||||
<ClientOnly>
|
||||
<template v-if="memberData">
|
||||
Signed in as {{ memberData.name }}
|
||||
<template v-if="memberData.circle">
|
||||
· {{ memberData.circle }}
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
A cooperative for game developers
|
||||
</template>
|
||||
<template #fallback>
|
||||
A cooperative for game developers
|
||||
<NuxtLink to="/member/profile" class="member-link">
|
||||
<img
|
||||
v-if="memberData.avatar"
|
||||
:src="`/ghosties/Ghost-${capitalize(memberData.avatar)}.png`"
|
||||
:alt="memberData.name"
|
||||
class="member-avatar"
|
||||
/>
|
||||
<svg
|
||||
v-else
|
||||
class="member-avatar default-ghost"
|
||||
viewBox="0 0 136 129"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polygon
|
||||
fill="currentColor"
|
||||
points="59.75 0 59.75 1.794 50.792 1.794 50.792 3.585 43.627 3.585 43.627 7.169 34.669 7.169 34.669 10.752 27.5 10.752 27.5 16.127 22.125 16.127 22.125 21.502 16.752 21.502 16.752 28.668 13.167 28.668 13.167 37.626 9.583 37.626 9.583 44.791 7.794 44.791 7.794 53.749 6 53.749 6 75.251 7.794 75.251 7.794 84.209 9.583 84.209 9.583 91.376 13.167 91.376 13.167 100.334 16.752 100.334 16.752 107.498 22.125 107.498 22.125 112.873 27.5 112.873 27.5 118.25 34.669 118.25 34.669 121.831 43.627 121.831 43.627 125.415 50.792 125.415 50.792 127.208 59.75 127.208 59.75 129 81.25 129 81.25 127.208 90.208 127.208 90.208 125.415 97.377 125.415 97.377 121.831 106.335 121.831 106.335 118.25 113.5 118.25 113.5 112.873 118.875 112.873 118.875 107.498 124.252 107.498 124.252 100.334 127.833 100.334 127.833 91.376 131.417 91.376 131.417 84.209 133.21 84.209 133.21 75.251 135 75.251 135 53.749 133.21 53.749 133.21 44.791 131.417 44.791 131.417 37.626 127.833 37.626 127.833 28.668 124.252 28.668 124.252 21.502 118.875 21.502 118.875 16.127 113.5 16.127 113.5 10.752 106.335 10.752 106.335 7.169 97.377 7.169 97.377 3.585 90.208 3.585 90.208 1.794 81.25 1.794 81.25 0"
|
||||
/>
|
||||
<polygon
|
||||
fill="currentColor"
|
||||
points="1.356 82 1.356 83.308 0 83.308 0 98.999 1.356 98.999 1.356 100.309 9.501 100.309 9.501 104.231 8.143 104.231 8.143 106.847 1.356 106.847 1.356 108.154 0 108.154 0 114.694 1.356 114.694 1.356 116 10.855 116 10.855 114.694 13.57 114.694 13.57 112.08 16.285 112.08 16.285 109.464 17.644 109.464 17.644 104.231 19 104.231 19 83.308 17.644 83.308 17.644 82"
|
||||
/>
|
||||
<g transform="translate(50, 38)" fill="#000">
|
||||
<polygon
|
||||
points="7.072 0.642 7.072 2.569 7.714 2.569 7.714 4.499 8.358 4.499 8.358 6.427 9 6.427 9 8.356 8.358 8.356 8.358 9 4.501 9 4.501 8.356 3.859 8.356 3.859 6.427 2.571 6.427 2.571 4.499 1.286 4.499 1.286 2.569 0 2.569 0 0.642 0.642 0.642 0.642 0 6.431 0 6.431 0.642"
|
||||
/>
|
||||
<polygon
|
||||
points="40.395 25 40.395 25.599 41 25.599 41 30.399 40.395 30.399 40.395 31 21.605 31 21.605 30.399 21 30.399 21 25.599 21.605 25.599 21.605 25"
|
||||
/>
|
||||
<polygon
|
||||
points="52.072 0.642 52.072 2.569 52.714 2.569 52.714 4.499 53.358 4.499 53.358 6.427 54 6.427 54 8.356 53.358 8.356 53.358 9 49.501 9 49.501 8.356 48.859 8.356 48.859 6.427 47.571 6.427 47.571 4.499 46.286 4.499 46.286 2.569 45 2.569 45 0.642 45.642 0.642 45.642 0 51.431 0 51.431 0.642"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{{ memberData.name }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template v-else> A cooperative for game developers </template>
|
||||
<template #fallback> A cooperative for game developers </template>
|
||||
</ClientOnly>
|
||||
</slot>
|
||||
</span>
|
||||
|
|
@ -25,16 +62,32 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
pagePath: { type: String, default: '' },
|
||||
})
|
||||
const props = defineProps({
|
||||
pagePath: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const { memberData } = useAuth()
|
||||
const { memberData } = useAuth();
|
||||
|
||||
const capitalize = (str) => {
|
||||
if (!str) return "";
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
};
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
if (!props.pagePath) return [];
|
||||
const segments = props.pagePath.split(" / ");
|
||||
let path = "";
|
||||
return segments.map((segment) => {
|
||||
path += "/" + segment.replace(/\s+/g, "-");
|
||||
return { label: segment, path };
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.top-strip {
|
||||
padding: 16px 32px;
|
||||
padding: 0 32px;
|
||||
min-height: 53px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
|
|
@ -42,6 +95,39 @@ const { memberData } = useAuth()
|
|||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.top-strip a { color: var(--text-faint); }
|
||||
.top-strip a:hover { color: var(--candle); }
|
||||
.top-strip a {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.top-strip a:hover {
|
||||
color: var(--candle);
|
||||
}
|
||||
.member-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.member-avatar {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.default-ghost {
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
.breadcrumb-nav {
|
||||
display: inline;
|
||||
}
|
||||
.breadcrumb-link {
|
||||
color: var(--text-faint);
|
||||
text-decoration: none;
|
||||
}
|
||||
.breadcrumb-link:hover {
|
||||
color: var(--candle);
|
||||
text-decoration: none;
|
||||
}
|
||||
.breadcrumb-sep {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -4,67 +4,115 @@
|
|||
<div class="about-hero">
|
||||
<div class="about-hero-left">
|
||||
<h1>About Ghost Guild</h1>
|
||||
<p>A membership community for game developers exploring cooperative business models.</p>
|
||||
<p>
|
||||
A membership community for game developers exploring cooperative
|
||||
business models.
|
||||
</p>
|
||||
</div>
|
||||
<div class="about-hero-right">
|
||||
<div class="section-label">Our Story</div>
|
||||
<p>Ghost Guild grew out of Baby Ghosts, a Canadian nonprofit that's been supporting indie game developers since 2018. We noticed a gap: game developers interested in cooperative models had nowhere to learn, practice, and connect with others doing the same work.</p>
|
||||
<p>Ghost Guild is the response — a membership program where developers at every stage of cooperative practice can find resources, events, mentorship, and community.</p>
|
||||
<p>We don't prescribe a single model. We're a place to explore the options, learn from people who've tried them, and build something that works for your team.</p>
|
||||
<p>
|
||||
Ghost Guild grew out of Baby Ghosts, a Canadian nonprofit that's been
|
||||
supporting indie game developers since 2018. We noticed a gap: game
|
||||
developers interested in cooperative models had nowhere to learn,
|
||||
practice, and connect with others doing the same work.
|
||||
</p>
|
||||
<p>
|
||||
Ghost Guild is the response — a membership program where
|
||||
developers at every stage of cooperative practice can find resources,
|
||||
events, mentorship, and community.
|
||||
</p>
|
||||
<p>
|
||||
We don't prescribe a single model. We're a place to explore the
|
||||
options, learn from people who've tried them, and build something that
|
||||
works for your team.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
|
||||
<div class="content-area">
|
||||
<div class="content-main">
|
||||
|
||||
<!-- THE CIRCLES -->
|
||||
<div class="about-section" id="circles">
|
||||
<div class="section-label">The Circles</div>
|
||||
<div class="circles-grid">
|
||||
<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>For anyone exploring cooperative models. Wiki access, public events, Slack community, monthly meetings.</p>
|
||||
<p>
|
||||
For anyone exploring cooperative models. Wiki access, public
|
||||
events, Slack community, monthly meetings.
|
||||
</p>
|
||||
</div>
|
||||
<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. Peer accelerator, mentorship, governance templates.</p>
|
||||
<p>
|
||||
For people actively building cooperatives. Peer accelerator,
|
||||
mentorship, governance templates.
|
||||
</p>
|
||||
</div>
|
||||
<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. Mentoring, teaching, shaping the program direction.</p>
|
||||
<p>
|
||||
For experienced practitioners. Mentoring, teaching, shaping the
|
||||
program direction.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HOW CONTRIBUTION WORKS -->
|
||||
<div class="about-section">
|
||||
<div class="section-label">How Contribution Works</div>
|
||||
<p>Membership is $0–50/month, pay what you can. Nobody is excluded for lack of funds. Your contribution supports infrastructure, events, and community resources.</p>
|
||||
<ul class="tier-list">
|
||||
<li><span class="tier-amt">$0</span> I need support right now</li>
|
||||
<li><span class="tier-amt">$5</span> I can contribute</li>
|
||||
<li><span class="tier-amt">$15</span> I can sustain the community</li>
|
||||
<li><span class="tier-amt">$30</span> I can support others too</li>
|
||||
<li><span class="tier-amt">$50</span> I want to sponsor multiple members</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- COMMUNITY -->
|
||||
<div class="about-section">
|
||||
<div class="section-label">Community</div>
|
||||
<p>We gather in Slack, at monthly meetings, and through peer support sessions. The wiki is our shared knowledge base — growing as members contribute. Events range from workshops to social hangs to deep-dive series.</p>
|
||||
<NuxtLink to="/join" class="cta">Join the Guild →</NuxtLink>
|
||||
<!-- TWO-COL: CONTRIBUTION + COMMUNITY -->
|
||||
<div class="two-col-row">
|
||||
<div class="about-section">
|
||||
<div class="section-label">How Contribution Works</div>
|
||||
<p>
|
||||
Membership is $0–50/month, pay what you can. Nobody is
|
||||
excluded for lack of funds. Your contribution supports
|
||||
infrastructure, events, and community resources.
|
||||
</p>
|
||||
<ul class="tier-list">
|
||||
<li><span class="tier-amt">$0</span> I need support right now</li>
|
||||
<li><span class="tier-amt">$5</span> I can contribute</li>
|
||||
<li>
|
||||
<span class="tier-amt">$15</span> I can sustain the community
|
||||
</li>
|
||||
<li>
|
||||
<span class="tier-amt">$30</span> I can support others too
|
||||
</li>
|
||||
<li>
|
||||
<span class="tier-amt">$50</span> I want to sponsor multiple
|
||||
members
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="about-section">
|
||||
<div class="section-label">Community</div>
|
||||
<p>
|
||||
We gather in Slack, at monthly meetings, and through peer support
|
||||
sessions. The wiki is our shared knowledge base — growing as
|
||||
members contribute. Events range from workshops to social hangs to
|
||||
deep-dive series.
|
||||
</p>
|
||||
<NuxtLink to="/join" class="cta">Join the Guild →</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ABOUT BABY GHOSTS -->
|
||||
<div class="about-section">
|
||||
<div class="section-label">About Baby Ghosts</div>
|
||||
<p>Ghost Guild is a program of Baby Ghosts, a Canadian nonprofit advancing cooperative models in game development. No tracking. No ads. No venture capital.</p>
|
||||
<p><a href="https://babyghosts.fund" target="_blank">babyghosts.fund →</a></p>
|
||||
<p>
|
||||
Ghost Guild is a program of Baby Ghosts, a Canadian nonprofit
|
||||
advancing cooperative models in game development. No tracking. No
|
||||
ads. No venture capital.
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://babyghosts.fund" target="_blank"
|
||||
>babyghosts.fund →</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -75,10 +123,10 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
const { data: upcomingEvents } = await useFetch('/api/events', {
|
||||
const { data: upcomingEvents } = await useFetch("/api/events", {
|
||||
query: { limit: 3, upcoming: true },
|
||||
default: () => [],
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -104,7 +152,7 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
|
|||
align-self: stretch;
|
||||
}
|
||||
.about-hero-left h1 {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
|
|
@ -176,9 +224,11 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
|
|||
padding: 20px;
|
||||
border-right: 1px dashed var(--border);
|
||||
}
|
||||
.circle-cell:last-child { border-right: none; }
|
||||
.circle-cell:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
.circle-cell h3 {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
|
|
@ -196,6 +246,19 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
|
|||
line-height: 1.65;
|
||||
}
|
||||
|
||||
/* ---- TWO-COL ROW ---- */
|
||||
.two-col-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.two-col-row .about-section {
|
||||
border-bottom: none;
|
||||
}
|
||||
.two-col-row .about-section:first-child {
|
||||
border-right: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
/* ---- TIER LIST ---- */
|
||||
.tier-list {
|
||||
list-style: none;
|
||||
|
|
@ -209,7 +272,9 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
|
|||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.tier-list li:last-child { border-bottom: none; }
|
||||
.tier-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.tier-amt {
|
||||
color: var(--text-bright);
|
||||
font-weight: 600;
|
||||
|
|
@ -218,13 +283,26 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
|
|||
|
||||
/* ---- RESPONSIVE ---- */
|
||||
@media (max-width: 1024px) {
|
||||
.content-area { grid-template-columns: 1fr; }
|
||||
.circles-grid { grid-template-columns: 1fr; }
|
||||
.content-area {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.circles-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.circle-cell {
|
||||
border-right: none;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.circle-cell:last-child { border-bottom: none; }
|
||||
.circle-cell:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.two-col-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.two-col-row .about-section:first-child {
|
||||
border-right: none;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.about-hero {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,18 @@
|
|||
<div>
|
||||
<!-- HERO -->
|
||||
<div class="hero">
|
||||
<h1>Ghost Guild is where game developers practice cooperative business models.</h1>
|
||||
<p>Resources, events, and a community of people figuring it out. Three circles, no hierarchy. $0–50/mo, pay what you can.</p>
|
||||
<h1>
|
||||
Ghost Guild is where game developers practice cooperative business
|
||||
models.
|
||||
</h1>
|
||||
<p>
|
||||
Resources, events, and a community of people figuring it out. Three
|
||||
circles, no hierarchy. $0–50/mo, pay what you can.
|
||||
</p>
|
||||
<div class="hero-links">
|
||||
<NuxtLink to="/join" class="hero-link primary">Become a member</NuxtLink>
|
||||
<NuxtLink to="/join" class="hero-link primary"
|
||||
>Become a member</NuxtLink
|
||||
>
|
||||
<NuxtLink to="/wiki" class="hero-link">Read the wiki</NuxtLink>
|
||||
<NuxtLink to="/about" class="hero-link">What is this?</NuxtLink>
|
||||
</div>
|
||||
|
|
@ -13,8 +21,14 @@
|
|||
|
||||
<!-- THREE CIRCLES -->
|
||||
<div class="content-row">
|
||||
<div v-for="circle in circleData" :key="circle.value" class="content-block">
|
||||
<div class="label" :style="{ color: `var(--c-${circle.value})` }">{{ circle.label }}</div>
|
||||
<div
|
||||
v-for="circle in circleData"
|
||||
:key="circle.value"
|
||||
class="content-block"
|
||||
>
|
||||
<div class="label" :style="{ color: `var(--c-${circle.value})` }">
|
||||
{{ circle.label }}
|
||||
</div>
|
||||
<h2>{{ circle.metaphor }}</h2>
|
||||
<p>{{ circle.blurb }}</p>
|
||||
<details>
|
||||
|
|
@ -33,9 +47,11 @@
|
|||
<div v-if="events?.length" class="event-list">
|
||||
<div v-for="event in events" :key="event._id" class="event-item">
|
||||
<div class="block-inset event-item-inner">
|
||||
<span class="event-date">{{ formatDate(event.date) }}</span>
|
||||
<span class="event-date">{{ formatDate(event.startDate) }}</span>
|
||||
<span class="event-title">
|
||||
<NuxtLink :to="`/events/${event._id}`">{{ event.title }}</NuxtLink>
|
||||
<NuxtLink :to="`/events/${event._id}`">{{
|
||||
event.title
|
||||
}}</NuxtLink>
|
||||
</span>
|
||||
<CircleBadge v-if="event.circle" :circle="event.circle" />
|
||||
</div>
|
||||
|
|
@ -76,10 +92,23 @@
|
|||
|
||||
<!-- PARCHMENT INSET -->
|
||||
<ParchmentInset>
|
||||
<div class="label" style="color: var(--candle-faint); opacity: 0.6; margin-bottom: 12px;">From the Wiki</div>
|
||||
<div
|
||||
class="label"
|
||||
style="color: var(--parch-text-dim); margin-bottom: 12px"
|
||||
>
|
||||
From the Wiki
|
||||
</div>
|
||||
<h2>What is a cooperative studio?</h2>
|
||||
<p>A cooperative studio is a game development company owned and governed by the people who work there. Decisions are made collectively. Profits are shared according to contribution, not ownership stake.</p>
|
||||
<p>The games industry is full of stories about crunch, layoffs, and studios that extract value from workers. Cooperatives are one alternative — not the only one, but one worth <a href="/wiki">practicing together</a>.</p>
|
||||
<p>
|
||||
A cooperative studio is a game development company owned and governed by
|
||||
the people who work there. Decisions are made collectively. Profits are
|
||||
shared according to contribution, not ownership stake.
|
||||
</p>
|
||||
<p>
|
||||
The games industry is full of stories about crunch, layoffs, and studios
|
||||
that extract value from workers. Cooperatives are one alternative — not
|
||||
the only one, but one worth <a href="/wiki">practicing together</a>.
|
||||
</p>
|
||||
<p><a href="/wiki">Read more in the wiki →</a></p>
|
||||
</ParchmentInset>
|
||||
</div>
|
||||
|
|
@ -88,42 +117,48 @@
|
|||
<script setup>
|
||||
definePageMeta({
|
||||
layout: "default",
|
||||
})
|
||||
});
|
||||
|
||||
const { data: events } = await useFetch('/api/events', {
|
||||
const { data: events } = await useFetch("/api/events", {
|
||||
query: { limit: 4, upcoming: true },
|
||||
default: () => [],
|
||||
})
|
||||
});
|
||||
|
||||
const circleData = [
|
||||
{
|
||||
value: 'community',
|
||||
label: 'Community',
|
||||
metaphor: 'The open hall',
|
||||
blurb: 'Arrival, curiosity, orientation. For anyone exploring cooperative models in game development. Access the wiki, public events, and Slack.',
|
||||
included: 'Wiki access, public events, Slack community, monthly guild meetings. Free or pay-what-you-can.',
|
||||
value: "community",
|
||||
label: "Community",
|
||||
metaphor: "The open hall",
|
||||
blurb:
|
||||
"Arrival, curiosity, orientation. For anyone exploring cooperative models in game development. Access the wiki, public events, and Slack.",
|
||||
included:
|
||||
"Wiki access, public events, Slack community, monthly guild meetings. Free or pay-what-you-can.",
|
||||
},
|
||||
{
|
||||
value: 'founder',
|
||||
label: 'Founder',
|
||||
metaphor: 'The workshop',
|
||||
blurb: 'For people actively building cooperatives. Structured practice, peer support, templates, and hands-on resources.',
|
||||
included: 'Everything in Community plus the peer accelerator, 1:1 mentorship matching, and Founder-only workshops.',
|
||||
value: "founder",
|
||||
label: "Founder",
|
||||
metaphor: "The workshop",
|
||||
blurb:
|
||||
"For people actively building cooperatives. Structured practice, peer support, templates, and hands-on resources.",
|
||||
included:
|
||||
"Everything in Community plus the peer accelerator, 1:1 mentorship matching, and Founder-only workshops.",
|
||||
},
|
||||
{
|
||||
value: 'practitioner',
|
||||
label: 'Practitioner',
|
||||
metaphor: 'The alcove',
|
||||
blurb: 'Where experience is shared and knowledge given back. Teaching, advising, shaping the program itself.',
|
||||
included: 'Everything in Founder plus the ability to mentor, propose events, contribute to the wiki, and help govern the Guild.',
|
||||
value: "practitioner",
|
||||
label: "Practitioner",
|
||||
metaphor: "The alcove",
|
||||
blurb:
|
||||
"Where experience is shared and knowledge given back. Teaching, advising, shaping the program itself.",
|
||||
included:
|
||||
"Everything in Founder plus the ability to mentor, propose events, contribute to the wiki, and help govern the Guild.",
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -133,7 +168,7 @@ const formatDate = (dateStr) => {
|
|||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.hero h1 {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
|
|
@ -200,9 +235,11 @@ const formatDate = (dateStr) => {
|
|||
padding-left: 28px;
|
||||
padding-right: 28px;
|
||||
}
|
||||
.content-block:last-child { border-right: none; }
|
||||
.content-block:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
.content-block h2 {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
|
|
@ -232,10 +269,10 @@ details summary {
|
|||
list-style: none;
|
||||
}
|
||||
details summary::before {
|
||||
content: '+ ';
|
||||
content: "+ ";
|
||||
}
|
||||
details[open] summary::before {
|
||||
content: '− ';
|
||||
content: "− ";
|
||||
}
|
||||
details p {
|
||||
margin-top: 8px;
|
||||
|
|
@ -250,7 +287,7 @@ details p {
|
|||
}
|
||||
.event-item-inner {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr auto;
|
||||
grid-template-columns: 60px 1fr auto;
|
||||
gap: 16px;
|
||||
align-items: baseline;
|
||||
padding-top: 10px;
|
||||
|
|
@ -260,10 +297,21 @@ details p {
|
|||
.content-row.two-col .event-item:hover .event-item-inner {
|
||||
padding-left: calc(28px + 4px);
|
||||
}
|
||||
.event-date { color: var(--text-faint); font-size: 12px; }
|
||||
.event-title { color: var(--text); font-size: 13px; }
|
||||
.event-title a { color: var(--text); text-decoration: none; }
|
||||
.event-title a:hover { color: var(--candle); }
|
||||
.event-date {
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
.event-title {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.event-title a {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.event-title a:hover {
|
||||
color: var(--candle);
|
||||
}
|
||||
|
||||
/* ---- WIKI LIST ---- */
|
||||
.wiki-item {
|
||||
|
|
@ -277,8 +325,13 @@ details p {
|
|||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.wiki-item a { color: var(--text); text-decoration: none; }
|
||||
.wiki-item a:hover { color: var(--candle); }
|
||||
.wiki-item a {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.wiki-item a:hover {
|
||||
color: var(--candle);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-faint);
|
||||
|
|
@ -295,7 +348,9 @@ details p {
|
|||
border-right: none;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.content-block:last-child { border-bottom: none; }
|
||||
.content-block:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.hero-links {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
|
|
|||
|
|
@ -3,18 +3,25 @@
|
|||
<!-- Unauthenticated -->
|
||||
<div v-if="!memberData" class="loading">
|
||||
<p>Please sign in to access your account settings.</p>
|
||||
<button class="btn btn-primary" @click="openLoginModal({ title: 'Sign in to manage your account' })">Sign In</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="openLoginModal({ title: 'Sign in to manage your account' })"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="account-authenticated">
|
||||
<!-- PAGE HEADER -->
|
||||
<PageHeader title="Account Settings" subtitle="Manage your membership and billing" />
|
||||
<PageHeader
|
||||
title="Account Settings"
|
||||
subtitle="Manage your membership and billing"
|
||||
/>
|
||||
|
||||
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
|
||||
<div class="content-area">
|
||||
<div class="page-content">
|
||||
<div class="account-columns">
|
||||
|
||||
<!-- LEFT COLUMN: Membership Status & Email -->
|
||||
<div class="account-col-left">
|
||||
<section class="account-section">
|
||||
|
|
@ -24,24 +31,42 @@
|
|||
<div class="membership-card">
|
||||
<div class="membership-row">
|
||||
<span class="membership-k">Status</span>
|
||||
<span class="membership-v">
|
||||
<span class="status-dot" :class="memberData.status || 'active'"></span>
|
||||
{{ memberData.status || 'Active' }}
|
||||
<span class="membership-v status-v">
|
||||
<span
|
||||
class="status-dot"
|
||||
:class="memberData.status || 'active'"
|
||||
></span>
|
||||
<span>{{
|
||||
formatStatus(memberData.status || "active")
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="membership-row">
|
||||
<span class="membership-k">Circle</span>
|
||||
<span class="membership-v" :style="{ color: `var(--c-${memberData.circle || 'community'})` }">
|
||||
{{ memberData.circle || 'Community' }}
|
||||
<span
|
||||
class="membership-v"
|
||||
:style="{
|
||||
color: `var(--c-${memberData.circle || 'community'})`,
|
||||
}"
|
||||
>
|
||||
{{
|
||||
memberData.circle
|
||||
? capitalise(memberData.circle)
|
||||
: "Community"
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="membership-row">
|
||||
<span class="membership-k">Contribution</span>
|
||||
<span class="membership-v">${{ memberData.contributionTier || 0 }} / month</span>
|
||||
<span class="membership-v"
|
||||
>${{ memberData.contributionTier || 0 }} / month</span
|
||||
>
|
||||
</div>
|
||||
<div class="membership-row">
|
||||
<span class="membership-k">Member since</span>
|
||||
<span class="membership-v">{{ formatMemberSince(memberData.createdAt) }}</span>
|
||||
<span class="membership-v">{{
|
||||
formatMemberSince(memberData.createdAt)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -50,10 +75,50 @@
|
|||
<section class="account-section">
|
||||
<div class="account-col-inset">
|
||||
<div class="section-label">Email</div>
|
||||
<div class="email-display">
|
||||
|
||||
<div v-if="!showEmailEdit" class="email-display">
|
||||
<span class="email-value">{{ memberData.email }}</span>
|
||||
<button
|
||||
class="btn btn-inline"
|
||||
@click="showEmailEdit = true"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="email-edit">
|
||||
<div class="field">
|
||||
<label>New email address</label>
|
||||
<input
|
||||
type="email"
|
||||
v-model="newEmail"
|
||||
placeholder="you@example.com"
|
||||
@keydown.enter="handleUpdateEmail"
|
||||
@keydown.escape="cancelEmailEdit"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="email-edit-actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="handleUpdateEmail"
|
||||
:disabled="isUpdatingEmail || !newEmail.trim()"
|
||||
>
|
||||
{{ isUpdatingEmail ? "Saving…" : "Save" }}
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
@click="cancelEmailEdit"
|
||||
:disabled="isUpdatingEmail"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="email-hint">
|
||||
Used for login magic links and notifications
|
||||
</div>
|
||||
<div class="email-hint">Used for login magic links and notifications</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -61,17 +126,34 @@
|
|||
<div class="account-col-inset">
|
||||
<div class="section-label danger">Danger Zone</div>
|
||||
<div class="danger-zone">
|
||||
<p>Cancelling your membership will immediately revoke access to member-only resources, events, and the Slack workspace. <strong>This action cannot be easily undone.</strong></p>
|
||||
<p>
|
||||
Cancelling your membership will immediately revoke access
|
||||
to member-only resources, events, and the Slack workspace.
|
||||
<strong>This action cannot be easily undone.</strong>
|
||||
</p>
|
||||
<div v-if="showCancelConfirm" class="cancel-confirm">
|
||||
<p class="cancel-confirm-prompt">Are you sure? This cannot be easily undone.</p>
|
||||
<p class="cancel-confirm-prompt">
|
||||
Are you sure? This cannot be easily undone.
|
||||
</p>
|
||||
<div class="cancel-confirm-actions">
|
||||
<button class="btn btn-danger" @click="confirmCancelMembership" :disabled="isCancelling">
|
||||
{{ isCancelling ? 'Cancelling...' : 'Yes, Cancel' }}
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
@click="confirmCancelMembership"
|
||||
:disabled="isCancelling"
|
||||
>
|
||||
{{ isCancelling ? "Cancelling…" : "Yes, Cancel" }}
|
||||
</button>
|
||||
<button class="btn" @click="showCancelConfirm = false">
|
||||
Nevermind
|
||||
</button>
|
||||
<button class="btn" @click="showCancelConfirm = false">Nevermind</button>
|
||||
</div>
|
||||
</div>
|
||||
<button v-else class="btn btn-danger" @click="handleCancelMembership" :disabled="isCancelling">
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-danger"
|
||||
@click="handleCancelMembership"
|
||||
:disabled="isCancelling"
|
||||
>
|
||||
Cancel Membership
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -86,13 +168,18 @@
|
|||
<div class="section-label">Change Contribution</div>
|
||||
|
||||
<TierPicker v-model="selectedTier" :tiers="tiers" />
|
||||
<div class="tier-hint">Changes take effect on your next billing cycle</div>
|
||||
<div class="tier-hint">
|
||||
Changes take effect on your next billing cycle
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary btn-section"
|
||||
@click="handleUpdateTier"
|
||||
:disabled="selectedTier === Number(memberData.contributionTier || 0) || isUpdating"
|
||||
:disabled="
|
||||
selectedTier ===
|
||||
Number(memberData.contributionTier || 0) || isUpdating
|
||||
"
|
||||
>
|
||||
{{ isUpdating ? 'Updating...' : 'Update Contribution' }}
|
||||
{{ isUpdating ? "Updating…" : "Update Contribution" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -101,13 +188,18 @@
|
|||
<div class="account-col-inset">
|
||||
<div class="section-label">Change Circle</div>
|
||||
|
||||
<CirclePicker v-model="selectedCircle" :circles="circleOptions" />
|
||||
<CirclePicker
|
||||
v-model="selectedCircle"
|
||||
:circles="circleOptions"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary btn-section"
|
||||
@click="handleUpdateCircle"
|
||||
:disabled="selectedCircle === memberData.circle || isUpdating"
|
||||
:disabled="
|
||||
selectedCircle === memberData.circle || isUpdating
|
||||
"
|
||||
>
|
||||
{{ isUpdating ? 'Updating...' : 'Update Circle' }}
|
||||
{{ isUpdating ? "Updating…" : "Update Circle" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -124,107 +216,187 @@
|
|||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
middleware: "auth",
|
||||
});
|
||||
|
||||
const { memberData, checkMemberStatus } = useAuth()
|
||||
const { openLoginModal } = useLoginModal()
|
||||
const toast = useToast()
|
||||
const { memberData, checkMemberStatus } = useAuth();
|
||||
const { openLoginModal } = useLoginModal();
|
||||
const toast = useToast();
|
||||
|
||||
const selectedTier = ref(0)
|
||||
const selectedCircle = ref('')
|
||||
const isUpdating = ref(false)
|
||||
const isCancelling = ref(false)
|
||||
const selectedTier = ref(0);
|
||||
const selectedCircle = ref("");
|
||||
const isUpdating = ref(false);
|
||||
const isCancelling = ref(false);
|
||||
|
||||
// Email edit state
|
||||
const showEmailEdit = ref(false);
|
||||
const newEmail = ref("");
|
||||
const isUpdatingEmail = ref(false);
|
||||
|
||||
const tiers = [
|
||||
{ amount: 0, display: '$0', label: 'Solidarity' },
|
||||
{ amount: 5, display: '$5', label: 'Supporter' },
|
||||
{ amount: 15, display: '$15', label: 'Sustainer' },
|
||||
{ amount: 30, display: '$30', label: 'Builder' },
|
||||
{ amount: 50, display: '$50', label: 'Champion' },
|
||||
]
|
||||
{ amount: 0, display: "$0", label: "Solidarity" },
|
||||
{ amount: 5, display: "$5", label: "Supporter" },
|
||||
{ amount: 15, display: "$15", label: "Sustainer" },
|
||||
{ amount: 30, display: "$30", label: "Builder" },
|
||||
{ amount: 50, display: "$50", label: "Champion" },
|
||||
];
|
||||
|
||||
const circleOptions = [
|
||||
{ value: 'community', label: 'Community', description: 'For anyone interested in cooperative game dev. Access discussions, events, and resources.' },
|
||||
{ value: 'founder', label: 'Founder', description: 'For those actively building or running a cooperative studio. Peer support and deep dives.' },
|
||||
{ value: 'practitioner', label: 'Practitioner', description: 'For professionals advising co-ops: lawyers, accountants, facilitators, consultants.' },
|
||||
]
|
||||
{
|
||||
value: "community",
|
||||
label: "Community",
|
||||
description:
|
||||
"For anyone interested in cooperative game dev. Access discussions, events, and resources.",
|
||||
},
|
||||
{
|
||||
value: "founder",
|
||||
label: "Founder",
|
||||
description:
|
||||
"For those actively building or running a cooperative studio. Peer support and deep dives.",
|
||||
},
|
||||
{
|
||||
value: "practitioner",
|
||||
label: "Practitioner",
|
||||
description:
|
||||
"For professionals advising co-ops: lawyers, accountants, facilitators, consultants.",
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_LABELS = {
|
||||
active: "Active",
|
||||
pending_payment: "Pending",
|
||||
suspended: "Suspended",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
|
||||
const formatStatus = (s) => STATUS_LABELS[s] || s;
|
||||
|
||||
const capitalise = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
||||
|
||||
// Initialize from member data
|
||||
watchEffect(() => {
|
||||
if (memberData.value) {
|
||||
selectedTier.value = Number(memberData.value.contributionTier || 0)
|
||||
selectedCircle.value = memberData.value.circle || 'community'
|
||||
selectedTier.value = Number(memberData.value.contributionTier || 0);
|
||||
selectedCircle.value = memberData.value.circle || "community";
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const { data: upcomingEvents } = await useFetch('/api/events', {
|
||||
const { data: upcomingEvents } = await useFetch("/api/events", {
|
||||
query: { limit: 3, upcoming: true },
|
||||
default: () => [],
|
||||
})
|
||||
});
|
||||
|
||||
const formatMemberSince = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
||||
}
|
||||
if (!dateStr) return "";
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateTier = async () => {
|
||||
isUpdating.value = true
|
||||
isUpdating.value = true;
|
||||
try {
|
||||
await $fetch('/api/members/update-contribution', {
|
||||
method: 'POST',
|
||||
await $fetch("/api/members/update-contribution", {
|
||||
method: "POST",
|
||||
body: { contributionTier: String(selectedTier.value) },
|
||||
})
|
||||
await checkMemberStatus()
|
||||
toast.add({ title: 'Contribution updated', color: 'green' })
|
||||
});
|
||||
await checkMemberStatus();
|
||||
toast.add({ title: "Contribution updated", color: "green" });
|
||||
} catch (err) {
|
||||
selectedTier.value = Number(memberData.value?.contributionTier || 0)
|
||||
toast.add({ title: 'Update failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' })
|
||||
selectedTier.value = Number(memberData.value?.contributionTier || 0);
|
||||
toast.add({
|
||||
title: "Update failed",
|
||||
description: err.data?.statusMessage || "Please try again.",
|
||||
color: "red",
|
||||
});
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
isUpdating.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateCircle = async () => {
|
||||
isUpdating.value = true
|
||||
isUpdating.value = true;
|
||||
try {
|
||||
await $fetch('/api/members/update-circle', {
|
||||
method: 'POST',
|
||||
await $fetch("/api/members/update-circle", {
|
||||
method: "POST",
|
||||
body: { circle: selectedCircle.value },
|
||||
})
|
||||
await checkMemberStatus()
|
||||
toast.add({ title: 'Circle updated', color: 'green' })
|
||||
});
|
||||
await checkMemberStatus();
|
||||
toast.add({ title: "Circle updated", color: "green" });
|
||||
} catch (err) {
|
||||
selectedCircle.value = memberData.value?.circle || 'community'
|
||||
toast.add({ title: 'Update failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' })
|
||||
selectedCircle.value = memberData.value?.circle || "community";
|
||||
toast.add({
|
||||
title: "Update failed",
|
||||
description: err.data?.statusMessage || "Please try again.",
|
||||
color: "red",
|
||||
});
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
isUpdating.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const showCancelConfirm = ref(false)
|
||||
const cancelEmailEdit = () => {
|
||||
showEmailEdit.value = false;
|
||||
newEmail.value = "";
|
||||
};
|
||||
|
||||
const handleUpdateEmail = async () => {
|
||||
const trimmed = newEmail.value.trim();
|
||||
if (!trimmed) return;
|
||||
isUpdatingEmail.value = true;
|
||||
try {
|
||||
await $fetch("/api/members/update-email", {
|
||||
method: "POST",
|
||||
body: { email: trimmed },
|
||||
});
|
||||
await checkMemberStatus();
|
||||
cancelEmailEdit();
|
||||
toast.add({ title: "Email updated", color: "green" });
|
||||
} catch (err) {
|
||||
toast.add({
|
||||
title: "Update failed",
|
||||
description: err.data?.statusMessage || "Please try again.",
|
||||
color: "red",
|
||||
});
|
||||
} finally {
|
||||
isUpdatingEmail.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const showCancelConfirm = ref(false);
|
||||
|
||||
const handleCancelMembership = () => {
|
||||
showCancelConfirm.value = true
|
||||
}
|
||||
showCancelConfirm.value = true;
|
||||
};
|
||||
|
||||
const confirmCancelMembership = async () => {
|
||||
showCancelConfirm.value = false
|
||||
isCancelling.value = true
|
||||
showCancelConfirm.value = false;
|
||||
isCancelling.value = true;
|
||||
try {
|
||||
const result = await $fetch('/api/members/cancel-subscription', { method: 'POST' })
|
||||
await checkMemberStatus()
|
||||
if (result.message === 'No active subscription to cancel') {
|
||||
toast.add({ title: 'No active subscription', description: 'You are on the free tier — nothing to cancel.', color: 'neutral' })
|
||||
const result = await $fetch("/api/members/cancel-subscription", {
|
||||
method: "POST",
|
||||
});
|
||||
await checkMemberStatus();
|
||||
if (result.message === "No active subscription to cancel") {
|
||||
toast.add({
|
||||
title: "No active subscription",
|
||||
description: "You are on the free tier — nothing to cancel.",
|
||||
color: "neutral",
|
||||
});
|
||||
} else {
|
||||
toast.add({ title: 'Membership cancelled', color: 'orange' })
|
||||
toast.add({ title: "Membership cancelled", color: "orange" });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Cancellation failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' })
|
||||
toast.add({
|
||||
title: "Cancellation failed",
|
||||
description: err.data?.statusMessage || "Please try again.",
|
||||
color: "red",
|
||||
});
|
||||
} finally {
|
||||
isCancelling.value = false
|
||||
isCancelling.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -286,18 +458,19 @@ const confirmCancelMembership = async () => {
|
|||
border-right: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
/* Full-column rules: border on block-level section (no hr / flex quirks) */
|
||||
/* Full-column rules: border on block-level section */
|
||||
.account-section {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.account-section + .account-section {
|
||||
margin-top: 20px;
|
||||
margin-top: 24px;
|
||||
border-top: 1px dashed var(--border);
|
||||
padding-top: 14px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
.account-section + .account-section.account-section--danger {
|
||||
border-top-color: var(--ember);
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.account-col-left > .account-section:first-child .account-col-inset,
|
||||
|
|
@ -330,7 +503,7 @@ const confirmCancelMembership = async () => {
|
|||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 0 12px;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
|
|
@ -345,42 +518,97 @@ const confirmCancelMembership = async () => {
|
|||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Status dot — flex row so gap is exact, no whitespace-node gap */
|
||||
.status-v {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-dot.active {
|
||||
background: var(--green);
|
||||
}
|
||||
.status-dot.suspended {
|
||||
background: var(--ember);
|
||||
}
|
||||
.status-dot.cancelled {
|
||||
background: var(--text-faint);
|
||||
}
|
||||
.status-dot.pending_payment {
|
||||
background: var(--candle);
|
||||
}
|
||||
.status-dot.active { background: var(--green); }
|
||||
.status-dot.suspended { background: var(--ember); }
|
||||
.status-dot.cancelled { background: var(--text-faint); }
|
||||
.status-dot.pending_payment { background: var(--candle); }
|
||||
|
||||
/* ---- EMAIL ---- */
|
||||
.email-display {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.email-value {
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-inline {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 8px;
|
||||
border-style: dashed;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.email-edit {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.email-edit .field {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.email-edit .field label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
margin-bottom: 3px;
|
||||
display: block;
|
||||
}
|
||||
.email-edit .field input {
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 13px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
.email-edit .field input:focus {
|
||||
border-color: var(--candle);
|
||||
}
|
||||
.email-edit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.email-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ---- DANGER ZONE ---- */
|
||||
.section-label.danger {
|
||||
.account-section--danger {
|
||||
background: var(--ember-bg);
|
||||
}
|
||||
|
||||
.account-section--danger .section-label.danger {
|
||||
color: var(--ember);
|
||||
}
|
||||
.danger-zone p {
|
||||
font-size: 12px;
|
||||
.account-section--danger .danger-zone p {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 12px;
|
||||
max-width: 400px;
|
||||
|
|
@ -390,6 +618,7 @@ const confirmCancelMembership = async () => {
|
|||
.cancel-confirm {
|
||||
border: 1px dashed var(--ember);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.cancel-confirm-prompt {
|
||||
font-size: 12px;
|
||||
|
|
@ -415,8 +644,12 @@ const confirmCancelMembership = async () => {
|
|||
|
||||
/* ---- RESPONSIVE ---- */
|
||||
@media (max-width: 1024px) {
|
||||
.content-area { grid-template-columns: 1fr; }
|
||||
.account-columns { grid-template-columns: 1fr; }
|
||||
.content-area {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.account-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.account-col-left {
|
||||
border-right: none;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
|
|
|
|||
|
|
@ -1,171 +1,227 @@
|
|||
<template>
|
||||
<div class="dashboard">
|
||||
<ClientOnly>
|
||||
<!-- Loading State -->
|
||||
<div v-if="authPending" class="loading-state">
|
||||
<div class="spinner" />
|
||||
<p>Loading your dashboard...</p>
|
||||
</div>
|
||||
|
||||
<!-- Unauthenticated State -->
|
||||
<div v-else-if="!memberData" class="unauth-state">
|
||||
<h2>Sign in required</h2>
|
||||
<p>Please sign in to access your member dashboard.</p>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="openLoginModal({ title: 'Sign in to your dashboard', description: 'Enter your email to access your member dashboard' })"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Content -->
|
||||
<template v-else>
|
||||
<div class="dashboard-body">
|
||||
<!-- Member Status Banner -->
|
||||
<MemberStatusBanner :dismissible="true" />
|
||||
|
||||
<!-- Welcome Header -->
|
||||
<div class="welcome">
|
||||
<h1>Welcome back, {{ memberData?.name }}</h1>
|
||||
<div class="meta">
|
||||
<CircleBadge :circle="memberData?.circle || 'community'" />
|
||||
<span>${{ memberData?.contributionTier }} CAD/mo</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 v-else-if="registeredEvents.length" class="event-list">
|
||||
<NuxtLink
|
||||
v-for="evt in registeredEvents"
|
||||
:key="evt._id"
|
||||
:to="`/events/${evt.slug || evt._id}`"
|
||||
class="event-item"
|
||||
>
|
||||
<span class="event-date">{{ formatEventDate(evt.startDate) }}</span>
|
||||
<span class="event-title">{{ evt.title }}</span>
|
||||
<CircleBadge v-if="evt.circle" :circle="evt.circle" />
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Calendar subscription -->
|
||||
<button class="calendar-btn" @click="copyCalendarLink">
|
||||
{{ calendarLinkCopied ? 'Link copied!' : 'Subscribe to calendar' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<p>You haven't registered for any upcoming events</p>
|
||||
</div>
|
||||
|
||||
<NuxtLink to="/events" class="section-link">Browse all events →</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 @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="/members?peerSupport=true"
|
||||
class="quick-action"
|
||||
:class="{ disabled: !canPeerSupport }"
|
||||
:title="!canPeerSupport ? 'Complete your membership to book peer sessions' : ''"
|
||||
>
|
||||
Book a peer session<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">
|
||||
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>
|
||||
|
||||
<!-- Membership Summary + Peer Support -->
|
||||
<div class="content-row">
|
||||
<div class="content-block">
|
||||
<div class="section-label">Your Membership</div>
|
||||
<div class="membership-row">
|
||||
<span class="key">Circle</span>
|
||||
<span class="val" :style="{ color: `var(--c-${memberData?.circle || 'community'})` }">
|
||||
{{ memberData?.circle }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="membership-row">
|
||||
<span class="key">Contribution</span>
|
||||
<span class="val">${{ memberData?.contributionTier }} CAD/month</span>
|
||||
</div>
|
||||
<div class="membership-row">
|
||||
<span class="key">Status</span>
|
||||
<span class="val">
|
||||
<span :class="isActive ? 'status-active' : ''">
|
||||
{{ isActive ? 'Active' : statusConfig.label }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="memberData?.createdAt" class="membership-row">
|
||||
<span class="key">Member since</span>
|
||||
<span class="val">{{ formatMemberSince(memberData.createdAt) }}</span>
|
||||
</div>
|
||||
<NuxtLink to="/member/profile#account" class="section-link">
|
||||
Change circle or contribution →
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="content-block">
|
||||
<div class="section-label">Peer Support</div>
|
||||
<DashedBox>
|
||||
<p class="peer-text">
|
||||
Interested in offering peer support? Set up your profile to connect with other members who share your interests and experience.
|
||||
</p>
|
||||
<NuxtLink to="/member/profile" class="section-link">
|
||||
Set up peer support →
|
||||
</NuxtLink>
|
||||
</DashedBox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #fallback>
|
||||
<div class="loading-state">
|
||||
<!-- Loading State -->
|
||||
<div v-if="authPending" class="loading-state">
|
||||
<div class="spinner" />
|
||||
<p>Loading your dashboard...</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Unauthenticated State -->
|
||||
<div v-else-if="!memberData" class="unauth-state">
|
||||
<h2>Sign in required</h2>
|
||||
<p>Please sign in to access your member dashboard.</p>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="
|
||||
openLoginModal({
|
||||
title: 'Sign in to your dashboard',
|
||||
description: 'Enter your email to access your member dashboard',
|
||||
})
|
||||
"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Content -->
|
||||
<template v-else>
|
||||
<div class="dashboard-with-sidebar">
|
||||
<div class="dashboard-body">
|
||||
<!-- Member Status Banner -->
|
||||
<MemberStatusBanner />
|
||||
|
||||
<!-- Welcome Header -->
|
||||
<div class="welcome">
|
||||
<h1>Welcome back, {{ memberData?.name }}</h1>
|
||||
<div class="meta">
|
||||
<CircleBadge :circle="memberData?.circle || 'community'" />
|
||||
<span>${{ memberData?.contributionTier }} CAD/mo</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 v-else-if="registeredEvents.length" class="event-list">
|
||||
<NuxtLink
|
||||
v-for="evt in registeredEvents"
|
||||
:key="evt._id"
|
||||
:to="`/events/${evt.slug || evt._id}`"
|
||||
class="event-item"
|
||||
>
|
||||
<span class="event-date">{{
|
||||
formatEventDate(evt.startDate)
|
||||
}}</span>
|
||||
<span class="event-title">{{ evt.title }}</span>
|
||||
<CircleBadge v-if="evt.circle" :circle="evt.circle" />
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Calendar subscription -->
|
||||
<button class="calendar-btn" @click="copyCalendarLink">
|
||||
{{
|
||||
calendarLinkCopied
|
||||
? "Link copied!"
|
||||
: "Subscribe to calendar"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<p>You haven't registered for any upcoming events</p>
|
||||
</div>
|
||||
|
||||
<NuxtLink to="/events" class="section-link"
|
||||
>Browse all events →</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
|
||||
@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="/members?peerSupport=true"
|
||||
class="quick-action"
|
||||
:class="{ disabled: !canPeerSupport }"
|
||||
:title="
|
||||
!canPeerSupport
|
||||
? 'Complete your membership to book peer sessions'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
Book a peer session<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"
|
||||
>
|
||||
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>
|
||||
|
||||
<!-- Membership Summary + Peer Support -->
|
||||
<div class="content-row">
|
||||
<div class="content-block">
|
||||
<div class="section-label">Your Membership</div>
|
||||
<div class="membership-row">
|
||||
<span class="key">Circle</span>
|
||||
<span
|
||||
class="val"
|
||||
:style="{
|
||||
color: `var(--c-${memberData?.circle || 'community'})`,
|
||||
}"
|
||||
>
|
||||
{{ memberData?.circle }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="membership-row">
|
||||
<span class="key">Contribution</span>
|
||||
<span class="val"
|
||||
>${{ memberData?.contributionTier }} CAD/month</span
|
||||
>
|
||||
</div>
|
||||
<div class="membership-row">
|
||||
<span class="key">Status</span>
|
||||
<span class="val">
|
||||
<span :class="isActive ? 'status-active' : ''">
|
||||
{{ isActive ? "Active" : statusConfig.label }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="memberData?.createdAt" class="membership-row">
|
||||
<span class="key">Member since</span>
|
||||
<span class="val">{{
|
||||
formatMemberSince(memberData.createdAt)
|
||||
}}</span>
|
||||
</div>
|
||||
<NuxtLink to="/member/profile#account" class="section-link">
|
||||
Change circle or contribution →
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="content-block">
|
||||
<div class="section-label">Peer Support</div>
|
||||
<DashedBox>
|
||||
<p class="peer-text">
|
||||
Interested in offering peer support? Set up your profile to
|
||||
connect with other members who share your interests and
|
||||
experience.
|
||||
</p>
|
||||
<NuxtLink to="/member/profile" class="section-link">
|
||||
Set up peer support →
|
||||
</NuxtLink>
|
||||
</DashedBox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EventsMiniSidebar :events="upcomingEvents" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #fallback>
|
||||
<div class="loading-state">
|
||||
<div class="spinner" />
|
||||
<p>Loading your dashboard...</p>
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { memberData, checkMemberStatus } = useAuth();
|
||||
const { isActive, statusConfig, isPendingPayment, canPeerSupport } = useMemberStatus();
|
||||
const { isActive, statusConfig, isPendingPayment, canPeerSupport } =
|
||||
useMemberStatus();
|
||||
const { completePayment, isProcessingPayment } = useMemberPayment();
|
||||
|
||||
const registeredEvents = ref([]);
|
||||
|
|
@ -173,6 +229,11 @@ const loadingEvents = ref(false);
|
|||
const calendarLinkCopied = ref(false);
|
||||
const showCalendarInstructions = ref(false);
|
||||
|
||||
const { data: upcomingEvents } = await useFetch("/api/events", {
|
||||
query: { limit: 5, upcoming: true },
|
||||
default: () => [],
|
||||
});
|
||||
|
||||
// Calendar subscription URL
|
||||
const calendarUrl = computed(() => {
|
||||
const memberId = memberData.value?._id || memberData.value?.id;
|
||||
|
|
@ -358,7 +419,9 @@ useHead({
|
|||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-inline {
|
||||
|
|
@ -381,7 +444,7 @@ useHead({
|
|||
}
|
||||
|
||||
.unauth-state h2 {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
|
|
@ -405,7 +468,7 @@ useHead({
|
|||
}
|
||||
|
||||
.welcome h1 {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
|
|
@ -421,12 +484,19 @@ useHead({
|
|||
}
|
||||
|
||||
/* ---- CONTENT GRID ---- */
|
||||
.dashboard-with-sidebar {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 200px;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dashboard-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content-row {
|
||||
|
|
@ -490,7 +560,7 @@ useHead({
|
|||
|
||||
/* ---- CALENDAR BUTTON ---- */
|
||||
.calendar-btn {
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 11px;
|
||||
color: var(--candle-dim);
|
||||
background: none;
|
||||
|
|
@ -636,7 +706,7 @@ useHead({
|
|||
}
|
||||
|
||||
.status-active::before {
|
||||
content: '';
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
|
|
@ -652,7 +722,17 @@ useHead({
|
|||
}
|
||||
|
||||
/* ---- RESPONSIVE ---- */
|
||||
@media (max-width: 1024px) {
|
||||
.dashboard-with-sidebar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-with-sidebar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.content-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
|
|
|||
3
app/pages/member/index.vue
Normal file
3
app/pages/member/index.vue
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<script setup>
|
||||
await navigateTo('/members', { redirectCode: 301 })
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load diff
514
app/pages/members/[id].vue
Normal file
514
app/pages/members/[id].vue
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
<template>
|
||||
<div class="profile-page">
|
||||
<!-- Loading State -->
|
||||
<div v-if="pending" class="loading-state">
|
||||
<p>Loading profile...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error / 404 State -->
|
||||
<div v-else-if="fetchError || !member" class="error-state">
|
||||
<p class="error-title">Member not found</p>
|
||||
<p class="error-sub">This profile doesn't exist or isn't public.</p>
|
||||
<NuxtLink to="/members" class="btn">← Back to Members</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Profile Content -->
|
||||
<div v-else class="profile-content">
|
||||
<!-- Header Area -->
|
||||
<div class="profile-header">
|
||||
<div class="profile-avatar">
|
||||
<img
|
||||
v-if="member.avatar"
|
||||
:src="`/ghosties/Ghost-${member.avatar.charAt(0).toUpperCase() + member.avatar.slice(1)}.png`"
|
||||
:alt="member.name"
|
||||
class="profile-avatar-img"
|
||||
/>
|
||||
<span v-else class="profile-initials">{{
|
||||
getInitials(member.name)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="profile-identity">
|
||||
<h1 class="profile-name">
|
||||
{{ member.name }}
|
||||
<span v-if="member.pronouns" class="profile-pronouns">{{
|
||||
member.pronouns
|
||||
}}</span>
|
||||
</h1>
|
||||
<div class="profile-meta">
|
||||
<span v-if="member.circle" class="badge" :class="member.circle">{{
|
||||
circleLabels[member.circle]
|
||||
}}</span>
|
||||
<template v-if="member.studio">
|
||||
<span class="meta-sep">·</span>
|
||||
<span class="profile-studio">{{ member.studio }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bio Section -->
|
||||
<div v-if="member.bio" class="profile-section">
|
||||
<div class="section-label">About</div>
|
||||
<div class="profile-bio" v-html="renderMarkdown(member.bio)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Location & Timezone -->
|
||||
<div v-if="member.location || member.timeZone" class="profile-section">
|
||||
<div class="section-label">Location</div>
|
||||
<p class="profile-detail">
|
||||
{{ [member.location, member.timeZone].filter(Boolean).join(" · ") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Offering Section -->
|
||||
<div
|
||||
v-if="member.offering?.tags?.length || member.offering?.text"
|
||||
class="profile-section"
|
||||
>
|
||||
<div class="section-label">Offering</div>
|
||||
<div v-if="member.offering.tags?.length" class="tag-list">
|
||||
<span
|
||||
v-for="tag in member.offering.tags"
|
||||
:key="tag"
|
||||
class="tag-pill"
|
||||
>{{ tag }}</span
|
||||
>
|
||||
</div>
|
||||
<p v-if="member.offering.text" class="profile-detail offering-text">
|
||||
{{ member.offering.text }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Looking For Section -->
|
||||
<div
|
||||
v-if="member.lookingFor?.tags?.length || member.lookingFor?.text"
|
||||
class="profile-section"
|
||||
>
|
||||
<div class="section-label">Looking for</div>
|
||||
<div v-if="member.lookingFor.tags?.length" class="tag-list">
|
||||
<span
|
||||
v-for="tag in member.lookingFor.tags"
|
||||
:key="tag"
|
||||
class="tag-pill"
|
||||
>{{ tag }}</span
|
||||
>
|
||||
</div>
|
||||
<p v-if="member.lookingFor.text" class="profile-detail looking-text">
|
||||
{{ member.lookingFor.text }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Social Links -->
|
||||
<div
|
||||
v-if="
|
||||
member.socialLinks && Object.values(member.socialLinks).some(Boolean)
|
||||
"
|
||||
class="profile-section"
|
||||
>
|
||||
<div class="section-label">Links</div>
|
||||
<div class="social-links">
|
||||
<a
|
||||
v-if="member.socialLinks.website"
|
||||
:href="member.socialLinks.website"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="social-link"
|
||||
>Website</a
|
||||
>
|
||||
<a
|
||||
v-if="member.socialLinks.itch"
|
||||
:href="member.socialLinks.itch"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="social-link"
|
||||
>itch.io</a
|
||||
>
|
||||
<a
|
||||
v-if="member.socialLinks.mastodon"
|
||||
:href="member.socialLinks.mastodon"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="social-link"
|
||||
>Mastodon</a
|
||||
>
|
||||
<a
|
||||
v-if="member.socialLinks.bluesky"
|
||||
:href="member.socialLinks.bluesky"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="social-link"
|
||||
>Bluesky</a
|
||||
>
|
||||
<a
|
||||
v-if="member.socialLinks.linkedin"
|
||||
:href="member.socialLinks.linkedin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="social-link"
|
||||
>LinkedIn</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Peer Support Section -->
|
||||
<div v-if="member.peerSupport?.enabled" class="profile-section">
|
||||
<div class="section-label">Peer Support</div>
|
||||
<div v-if="member.peerSupport.skillTopics?.length" class="peer-group">
|
||||
<span class="peer-label">Skills:</span>
|
||||
<div class="tag-list">
|
||||
<span
|
||||
v-for="topic in member.peerSupport.skillTopics"
|
||||
:key="topic"
|
||||
class="tag-pill"
|
||||
>{{ topic }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="member.peerSupport.supportTopics?.length" class="peer-group">
|
||||
<span class="peer-label">Topics:</span>
|
||||
<div class="tag-list">
|
||||
<span
|
||||
v-for="topic in member.peerSupport.supportTopics"
|
||||
:key="topic"
|
||||
class="tag-pill"
|
||||
>{{ topic }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="member.peerSupport.availability" class="profile-detail">
|
||||
{{ member.peerSupport.availability }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Auth Notice -->
|
||||
<div v-if="!isAuthenticated" class="auth-notice">
|
||||
<p>Sign in to see full profile details</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
@click="
|
||||
openLoginModal({
|
||||
title: 'Sign in to see more',
|
||||
description: 'Log in to view full member profiles',
|
||||
})
|
||||
"
|
||||
>
|
||||
Log In
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Back Link -->
|
||||
<div class="profile-back">
|
||||
<NuxtLink to="/members" class="back-link">← Back to Members</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const route = useRoute();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { openLoginModal } = useLoginModal();
|
||||
const { render: renderMarkdown } = useMarkdown();
|
||||
|
||||
const id = route.params.id;
|
||||
|
||||
const circleLabels = {
|
||||
community: "Community",
|
||||
founder: "Founder",
|
||||
practitioner: "Practitioner",
|
||||
};
|
||||
|
||||
const getInitials = (name) => {
|
||||
if (!name) return "?";
|
||||
return name
|
||||
.split(" ")
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
// Fetch member data — no await so the component renders immediately (no Suspense)
|
||||
const { data, pending, error: fetchError } = useFetch(`/api/members/${id}`);
|
||||
const member = computed(() => data.value?.member || null);
|
||||
|
||||
// Page head
|
||||
useHead({
|
||||
title: computed(() =>
|
||||
member.value
|
||||
? `${member.value.name} — Ghost Guild`
|
||||
: "Member Profile — Ghost Guild",
|
||||
),
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-page {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px 60px;
|
||||
}
|
||||
|
||||
/* ---- LOADING ---- */
|
||||
.loading-state {
|
||||
padding: 80px 24px;
|
||||
text-align: center;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
font-family: "Commit Mono", monospace;
|
||||
}
|
||||
|
||||
/* ---- ERROR / 404 ---- */
|
||||
.error-state {
|
||||
padding: 80px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 20px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.error-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ---- HEADER ---- */
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 28px 0 24px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-avatar-img {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.profile-initials {
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 14px;
|
||||
color: var(--text-faint);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profile-identity {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.profile-pronouns {
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
font-weight: 400;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.profile-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.meta-sep {
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
.profile-studio {
|
||||
font-family: "Commit Mono", monospace;
|
||||
}
|
||||
|
||||
/* ---- SECTIONS ---- */
|
||||
.profile-section {
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-faint);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.profile-bio {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.profile-bio :deep(p) {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.profile-bio :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.profile-bio :deep(a) {
|
||||
color: var(--candle);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.profile-bio :deep(a:hover) {
|
||||
color: var(--ember);
|
||||
}
|
||||
|
||||
.profile-detail {
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.offering-text,
|
||||
.looking-text {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ---- TAGS ---- */
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag-pill {
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
padding: 2px 8px;
|
||||
border: 1px dashed var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- SOCIAL LINKS ---- */
|
||||
.social-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.social-link {
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 11px;
|
||||
color: var(--candle);
|
||||
text-decoration: none;
|
||||
padding: 3px 10px;
|
||||
border: 1px dashed var(--border);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.social-link:hover {
|
||||
border-color: var(--candle);
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
/* ---- PEER SUPPORT ---- */
|
||||
.peer-group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.peer-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.peer-label {
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* ---- AUTH NOTICE ---- */
|
||||
.auth-notice {
|
||||
padding: 20px;
|
||||
margin-top: 24px;
|
||||
border: 1px dashed var(--candle-faint, var(--border));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-notice p {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
/* ---- BACK LINK ---- */
|
||||
.profile-back {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--candle);
|
||||
}
|
||||
|
||||
/* ---- RESPONSIVE ---- */
|
||||
@media (max-width: 768px) {
|
||||
.profile-page {
|
||||
padding: 0 16px 40px;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
padding: 20px 0 18px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.profile-pronouns {
|
||||
display: block;
|
||||
margin-left: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
padding: 16px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -14,13 +14,17 @@
|
|||
class="filter-search"
|
||||
placeholder="Search members..."
|
||||
@input="debouncedSearch"
|
||||
>
|
||||
/>
|
||||
<select
|
||||
v-model="selectedCircle"
|
||||
class="filter-select"
|
||||
@change="loadMembers"
|
||||
>
|
||||
<option v-for="opt in circleOptions" :key="opt.value" :value="opt.value">
|
||||
<option
|
||||
v-for="opt in circleOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
|
|
@ -29,17 +33,25 @@
|
|||
type="checkbox"
|
||||
:checked="peerSupportFilter === 'true'"
|
||||
@change="togglePeerSupport"
|
||||
>
|
||||
/>
|
||||
Offering support
|
||||
</label>
|
||||
<span class="filter-count">Showing {{ totalCount }} member{{ totalCount === 1 ? '' : 's' }}</span>
|
||||
<span class="filter-count"
|
||||
>Showing {{ totalCount }} member{{ totalCount === 1 ? "" : "s" }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Skills Filter -->
|
||||
<div v-if="availableSkills && availableSkills.length > 0" class="skills-bar">
|
||||
<div
|
||||
v-if="availableSkills && availableSkills.length > 0"
|
||||
class="skills-bar"
|
||||
>
|
||||
<span class="tag-label">Skills:</span>
|
||||
<button
|
||||
v-for="skill in (availableSkills || []).slice(0, showAllSkills ? undefined : 10)"
|
||||
v-for="skill in (availableSkills || []).slice(
|
||||
0,
|
||||
showAllSkills ? undefined : 10,
|
||||
)"
|
||||
:key="skill"
|
||||
type="button"
|
||||
class="skill-tag"
|
||||
|
|
@ -54,15 +66,23 @@
|
|||
class="more-btn"
|
||||
@click="showAllSkills = !showAllSkills"
|
||||
>
|
||||
{{ showAllSkills ? 'Show less' : `+${availableSkills.length - 10} more` }}
|
||||
{{
|
||||
showAllSkills ? "Show less" : `+${availableSkills.length - 10} more`
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Topics Filter -->
|
||||
<div v-if="availableTopics && availableTopics.length > 0" class="skills-bar">
|
||||
<div
|
||||
v-if="availableTopics && availableTopics.length > 0"
|
||||
class="skills-bar"
|
||||
>
|
||||
<span class="tag-label">Topics:</span>
|
||||
<button
|
||||
v-for="topic in (availableTopics || []).slice(0, showAllTopics ? undefined : 10)"
|
||||
v-for="topic in (availableTopics || []).slice(
|
||||
0,
|
||||
showAllTopics ? undefined : 10,
|
||||
)"
|
||||
:key="topic"
|
||||
type="button"
|
||||
class="skill-tag"
|
||||
|
|
@ -77,20 +97,16 @@
|
|||
class="more-btn"
|
||||
@click="showAllTopics = !showAllTopics"
|
||||
>
|
||||
{{ showAllTopics ? 'Show less' : `+${availableTopics.length - 10} more` }}
|
||||
{{
|
||||
showAllTopics ? "Show less" : `+${availableTopics.length - 10} more`
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Active Filters -->
|
||||
<div
|
||||
v-if="hasActiveFilters"
|
||||
class="active-filters"
|
||||
>
|
||||
<div v-if="hasActiveFilters" class="active-filters">
|
||||
<span class="af-label">Active filters:</span>
|
||||
<span
|
||||
v-if="selectedCircle && selectedCircle !== 'all'"
|
||||
class="af-tag"
|
||||
>
|
||||
<span v-if="selectedCircle && selectedCircle !== 'all'" class="af-tag">
|
||||
{{ circleLabels[selectedCircle] }}
|
||||
<button type="button" @click="clearCircleFilter">×</button>
|
||||
</span>
|
||||
|
|
@ -101,19 +117,11 @@
|
|||
Offering Peer Support
|
||||
<button type="button" @click="clearPeerSupportFilter">×</button>
|
||||
</span>
|
||||
<span
|
||||
v-for="skill in selectedSkills"
|
||||
:key="'s-' + skill"
|
||||
class="af-tag"
|
||||
>
|
||||
<span v-for="skill in selectedSkills" :key="'s-' + skill" class="af-tag">
|
||||
{{ skill }}
|
||||
<button type="button" @click="toggleSkill(skill)">×</button>
|
||||
</span>
|
||||
<span
|
||||
v-for="topic in selectedTopics"
|
||||
:key="'t-' + topic"
|
||||
class="af-tag"
|
||||
>
|
||||
<span v-for="topic in selectedTopics" :key="'t-' + topic" class="af-tag">
|
||||
{{ topic }}
|
||||
<button type="button" @click="toggleTopic(topic)">×</button>
|
||||
</span>
|
||||
|
|
@ -134,11 +142,7 @@
|
|||
|
||||
<!-- Member Grid -->
|
||||
<div v-else-if="members.length > 0" class="member-grid">
|
||||
<div
|
||||
v-for="member in members"
|
||||
:key="member._id"
|
||||
class="member-card"
|
||||
>
|
||||
<div v-for="member in members" :key="member._id" class="member-card">
|
||||
<div class="mc-head">
|
||||
<div class="mc-avatar">
|
||||
<img
|
||||
|
|
@ -146,17 +150,22 @@
|
|||
:src="`/ghosties/Ghost-${member.avatar.charAt(0).toUpperCase() + member.avatar.slice(1)}.png`"
|
||||
:alt="member.name"
|
||||
class="mc-avatar-img"
|
||||
>
|
||||
/>
|
||||
<span v-else>{{ getInitials(member.name) }}</span>
|
||||
</div>
|
||||
<div class="mc-info">
|
||||
<div class="mc-name">
|
||||
<NuxtLink v-if="isAuthenticated" :to="`/members/${member._id}`">{{ member.name }}</NuxtLink>
|
||||
<span v-else>{{ member.name }}</span>
|
||||
<span v-if="member.pronouns" class="mc-pronouns">{{ member.pronouns }}</span>
|
||||
<NuxtLink :to="`/members/${member._id}`">{{
|
||||
member.name
|
||||
}}</NuxtLink>
|
||||
<span v-if="member.pronouns" class="mc-pronouns">{{
|
||||
member.pronouns
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="mc-meta">
|
||||
<span class="badge" :class="member.circle">{{ circleLabels[member.circle] }}</span>
|
||||
<span class="badge" :class="member.circle">{{
|
||||
circleLabels[member.circle]
|
||||
}}</span>
|
||||
<template v-if="member.studio">
|
||||
<span class="sep">·</span>
|
||||
{{ member.studio }}
|
||||
|
|
@ -172,7 +181,9 @@
|
|||
></div>
|
||||
|
||||
<div v-if="member.location || member.timeZone" class="mc-location">
|
||||
{{ [member.location, member.timeZone].filter(Boolean).join(' \u00b7 ') }}
|
||||
{{
|
||||
[member.location, member.timeZone].filter(Boolean).join(" \u00b7 ")
|
||||
}}
|
||||
</div>
|
||||
|
||||
<!-- Skills tags -->
|
||||
|
|
@ -185,7 +196,8 @@
|
|||
v-for="tag in member.offering.tags"
|
||||
:key="tag"
|
||||
class="skill-tag"
|
||||
>{{ tag }}</span>
|
||||
>{{ tag }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Looking for -->
|
||||
|
|
@ -193,12 +205,14 @@
|
|||
v-if="member.lookingFor?.tags && member.lookingFor.tags.length > 0"
|
||||
class="mc-looking"
|
||||
>
|
||||
Looking for: {{ member.lookingFor.tags.join(', ') }}
|
||||
Looking for: {{ member.lookingFor.tags.join(", ") }}
|
||||
</div>
|
||||
|
||||
<!-- Peer support session link -->
|
||||
<a
|
||||
v-if="member.peerSupport?.enabled && member.peerSupport?.slackUsername"
|
||||
v-if="
|
||||
member.peerSupport?.enabled && member.peerSupport?.slackUsername
|
||||
"
|
||||
href="#"
|
||||
class="mc-session"
|
||||
@click.prevent="openSlackDM(member)"
|
||||
|
|
@ -212,25 +226,33 @@
|
|||
<div v-else class="empty-state">
|
||||
<p class="empty-title">No members found</p>
|
||||
<p class="empty-sub">Try adjusting your search or filters</p>
|
||||
<button type="button" class="btn" @click="clearAllFilters">Clear Filters</button>
|
||||
<button type="button" class="btn" @click="clearAllFilters">
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Load more / count -->
|
||||
<div v-if="members.length > 0" class="load-more">
|
||||
<span>Showing {{ members.length }} of {{ totalCount }} member{{ totalCount === 1 ? '' : 's' }}</span>
|
||||
<span
|
||||
>Showing {{ members.length }} of {{ totalCount }} member{{
|
||||
totalCount === 1 ? "" : "s"
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Not Authenticated Notice -->
|
||||
<div
|
||||
v-if="!isAuthenticated && members.length > 0"
|
||||
class="auth-notice"
|
||||
>
|
||||
<div v-if="!isAuthenticated && members.length > 0" class="auth-notice">
|
||||
<p>Some member information is visible to members only.</p>
|
||||
<div class="auth-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
@click="openLoginModal({ title: 'Sign in to see more', description: 'Log in to view full member profiles' })"
|
||||
@click="
|
||||
openLoginModal({
|
||||
title: 'Sign in to see more',
|
||||
description: 'Log in to view full member profiles',
|
||||
})
|
||||
"
|
||||
>
|
||||
Log In
|
||||
</button>
|
||||
|
|
@ -282,8 +304,8 @@ const peerSupportOptions = [
|
|||
// Computed: has active filters
|
||||
const hasActiveFilters = computed(() => {
|
||||
return (
|
||||
(selectedCircle.value && selectedCircle.value !== 'all') ||
|
||||
(peerSupportFilter.value && peerSupportFilter.value !== 'all') ||
|
||||
(selectedCircle.value && selectedCircle.value !== "all") ||
|
||||
(peerSupportFilter.value && peerSupportFilter.value !== "all") ||
|
||||
selectedSkills.value.length > 0 ||
|
||||
selectedTopics.value.length > 0
|
||||
);
|
||||
|
|
@ -291,11 +313,11 @@ const hasActiveFilters = computed(() => {
|
|||
|
||||
// Get initials from name
|
||||
const getInitials = (name) => {
|
||||
if (!name) return '?';
|
||||
if (!name) return "?";
|
||||
return name
|
||||
.split(' ')
|
||||
.split(" ")
|
||||
.map((w) => w[0])
|
||||
.join('')
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
|
@ -335,7 +357,7 @@ const loadMembers = async () => {
|
|||
|
||||
// Toggle peer support checkbox
|
||||
const togglePeerSupport = (e) => {
|
||||
peerSupportFilter.value = e.target.checked ? 'true' : 'all';
|
||||
peerSupportFilter.value = e.target.checked ? "true" : "all";
|
||||
loadMembers();
|
||||
};
|
||||
|
||||
|
|
@ -440,7 +462,7 @@ useHead({
|
|||
}
|
||||
|
||||
.filter-search {
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 12px;
|
||||
padding: 5px 12px;
|
||||
border: 1px dashed var(--border);
|
||||
|
|
@ -458,7 +480,7 @@ useHead({
|
|||
}
|
||||
|
||||
.filter-select {
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 11px;
|
||||
padding: 5px 10px;
|
||||
border: 1px dashed var(--border);
|
||||
|
|
@ -514,7 +536,7 @@ useHead({
|
|||
}
|
||||
|
||||
.skills-bar .skill-tag {
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
padding: 2px 8px;
|
||||
|
|
@ -536,7 +558,7 @@ useHead({
|
|||
}
|
||||
|
||||
.more-btn {
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 10px;
|
||||
color: var(--candle);
|
||||
background: none;
|
||||
|
|
@ -587,7 +609,7 @@ useHead({
|
|||
}
|
||||
|
||||
.clear-all-btn {
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 10px;
|
||||
color: var(--candle);
|
||||
background: none;
|
||||
|
|
@ -743,7 +765,7 @@ useHead({
|
|||
.mc-session {
|
||||
display: inline-block;
|
||||
margin-top: 6px;
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
|
|
@ -778,7 +800,7 @@ useHead({
|
|||
text-align: center;
|
||||
}
|
||||
.empty-title {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 18px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
|
|
@ -9,20 +9,7 @@ export default defineNuxtConfig({
|
|||
classSuffix: "",
|
||||
},
|
||||
app: {
|
||||
head: {
|
||||
link: [
|
||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||
{
|
||||
rel: "preconnect",
|
||||
href: "https://fonts.gstatic.com",
|
||||
crossorigin: "",
|
||||
},
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Brygada+1918:ital,wght@0,400..700;1,400..700&family=Commit+Mono&display=swap",
|
||||
},
|
||||
],
|
||||
},
|
||||
head: {},
|
||||
},
|
||||
build: {
|
||||
transpile: ["vue-cal"],
|
||||
|
|
|
|||
20
package-lock.json
generated
20
package-lock.json
generated
|
|
@ -8,6 +8,8 @@
|
|||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@cloudinary/vue": "^1.13.3",
|
||||
"@fontsource-variable/brygada-1918": "^5.2.8",
|
||||
"@fontsource/commit-mono": "^5.2.5",
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
"@nuxt/eslint": "^1.9.0",
|
||||
|
|
@ -1668,6 +1670,24 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/brygada-1918": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/brygada-1918/-/brygada-1918-5.2.8.tgz",
|
||||
"integrity": "sha512-uJVruRjPIzihO6VuHeVhe8c+HAyYfvhr7h6ewSzeQ6Pqdspr86zh9p3+SZucUNoiSwy7fgaiei5eqx8+eePeIg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/commit-mono": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/commit-mono/-/commit-mono-5.2.5.tgz",
|
||||
"integrity": "sha512-htX8yQWtiPt5L1Hzh4sirvfUJT2+KYiquDB/Q2sY2tWQYplpBUOD5zHnIM3k36Hnm4V+JIIqA/wmwupSQ68WjA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@headlessui/vue": {
|
||||
"version": "1.7.23",
|
||||
"resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@cloudinary/vue": "^1.13.3",
|
||||
"@fontsource-variable/brygada-1918": "^5.2.8",
|
||||
"@fontsource/commit-mono": "^5.2.5",
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
"@nuxt/eslint": "^1.9.0",
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
const config = useRuntimeConfig(event)
|
||||
const token = jwt.sign(
|
||||
{ memberId: member._id, email: member.email },
|
||||
{ memberId: member._id, email: member.email, tv: member.tokenVersion },
|
||||
config.jwtSecret,
|
||||
{ expiresIn: '7d' }
|
||||
)
|
||||
|
|
|
|||
19
server/api/dev/members.get.js
Normal file
19
server/api/dev/members.get.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import Member from '../../models/member.js'
|
||||
import { connectDB } from '../../utils/mongoose.js'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Not found' })
|
||||
}
|
||||
|
||||
await connectDB()
|
||||
|
||||
const members = await Member.find({}, 'name email circle role status').sort({ name: 1 }).lean()
|
||||
|
||||
return members.map((m) => ({
|
||||
label: `${m.name} (${m.email})`,
|
||||
value: m.email,
|
||||
circle: m.circle,
|
||||
role: m.role
|
||||
}))
|
||||
})
|
||||
|
|
@ -26,7 +26,7 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
const config = useRuntimeConfig(event)
|
||||
const token = jwt.sign(
|
||||
{ memberId: member._id, email: member.email },
|
||||
{ memberId: member._id, email: member.email, tv: member.tokenVersion },
|
||||
config.jwtSecret,
|
||||
{ expiresIn: '7d' }
|
||||
)
|
||||
|
|
|
|||
96
server/api/members/[id].get.js
Normal file
96
server/api/members/[id].get.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
import Member from "../../models/member.js";
|
||||
import { connectDB } from "../../utils/mongoose.js";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await connectDB();
|
||||
|
||||
// Check if user is authenticated (optional — works for public and authenticated users)
|
||||
const token = getCookie(event, "auth-token");
|
||||
let isAuthenticated = false;
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, useRuntimeConfig().jwtSecret);
|
||||
if (decoded.memberId) {
|
||||
isAuthenticated = true;
|
||||
}
|
||||
} catch {
|
||||
// Invalid token, treat as public
|
||||
isAuthenticated = false;
|
||||
}
|
||||
}
|
||||
|
||||
const id = event.context.params.id;
|
||||
|
||||
try {
|
||||
const member = await Member.findOne({
|
||||
_id: id,
|
||||
showInDirectory: true,
|
||||
status: "active",
|
||||
})
|
||||
.select(
|
||||
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport createdAt",
|
||||
)
|
||||
.lean();
|
||||
|
||||
if (!member) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: "Member not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Filter fields based on privacy settings
|
||||
const privacy = member.privacy || {};
|
||||
const filtered = {
|
||||
_id: member._id,
|
||||
name: member.name,
|
||||
circle: member.circle,
|
||||
createdAt: member.createdAt,
|
||||
};
|
||||
|
||||
// Helper function to check if field should be visible
|
||||
const isVisible = (field) => {
|
||||
const privacySetting = privacy[field] || "members";
|
||||
if (privacySetting === "public") return true;
|
||||
if (privacySetting === "members" && isAuthenticated) return true;
|
||||
if (privacySetting === "private") return false;
|
||||
return false;
|
||||
};
|
||||
|
||||
// Add fields based on privacy settings
|
||||
if (isVisible("avatar")) filtered.avatar = member.avatar;
|
||||
if (isVisible("pronouns")) filtered.pronouns = member.pronouns;
|
||||
if (isVisible("timeZone")) filtered.timeZone = member.timeZone;
|
||||
if (isVisible("studio")) filtered.studio = member.studio;
|
||||
if (isVisible("bio")) filtered.bio = member.bio;
|
||||
if (isVisible("location")) filtered.location = member.location;
|
||||
if (isVisible("socialLinks")) filtered.socialLinks = member.socialLinks;
|
||||
if (isVisible("offering")) filtered.offering = member.offering;
|
||||
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
|
||||
|
||||
// Peer support: expose only fields needed for matching/contact UX
|
||||
// slackUserId, slackDMChannelId, slackUsername, personalMessage are internal
|
||||
if (member.peerSupport?.enabled) {
|
||||
filtered.peerSupport = {
|
||||
enabled: true,
|
||||
skillTopics: member.peerSupport.skillTopics,
|
||||
supportTopics: member.peerSupport.supportTopics,
|
||||
availability: member.peerSupport.availability,
|
||||
};
|
||||
}
|
||||
|
||||
return { member: filtered };
|
||||
} catch (error) {
|
||||
// Re-throw NuxtErrors (like the 404 above)
|
||||
if (error.statusCode) {
|
||||
throw error;
|
||||
}
|
||||
console.error("Member profile fetch error:", error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: "Failed to fetch member profile",
|
||||
});
|
||||
}
|
||||
});
|
||||
77
server/api/members/update-email.post.js
Normal file
77
server/api/members/update-email.post.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// Update member's email address
|
||||
import Member from '../../models/member.js'
|
||||
import { connectDB } from '../../utils/mongoose.js'
|
||||
import { requireAuth } from '../../utils/auth.js'
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const member = await requireAuth(event)
|
||||
await connectDB()
|
||||
|
||||
const body = await readBody(event)
|
||||
const newEmail = (body?.email ?? '').trim().toLowerCase()
|
||||
|
||||
if (!newEmail) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Email address is required',
|
||||
})
|
||||
}
|
||||
|
||||
if (!EMAIL_REGEX.test(newEmail)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid email address format',
|
||||
})
|
||||
}
|
||||
|
||||
const oldEmail = member.email.trim().toLowerCase()
|
||||
|
||||
if (newEmail === oldEmail) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'New email address must be different from your current email',
|
||||
})
|
||||
}
|
||||
|
||||
const existing = await Member.findOne({
|
||||
email: newEmail,
|
||||
_id: { $ne: member._id },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: 'This email address is already in use by another account',
|
||||
})
|
||||
}
|
||||
|
||||
await Member.findByIdAndUpdate(
|
||||
member._id,
|
||||
{
|
||||
$set: { email: newEmail },
|
||||
$push: {
|
||||
emailHistory: {
|
||||
email: oldEmail,
|
||||
changedAt: new Date(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{ runValidators: false }
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
email: newEmail,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.statusCode) throw error
|
||||
console.error('Error updating email:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'An unexpected error occurred',
|
||||
})
|
||||
}
|
||||
})
|
||||
109
server/migrations/import-babyghosts-preregistrations.js
Normal file
109
server/migrations/import-babyghosts-preregistrations.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// Migration: copy pre-registrations from babyghosts → ghostguild
|
||||
//
|
||||
// Usage (from ghostguild-org root):
|
||||
// BG_MONGODB_URI="<babyghosts uri>" node server/migrations/import-babyghosts-preregistrations.js
|
||||
//
|
||||
// The ghostguild URI is read from NUXT_MONGODB_URI or MONGODB_URI in your .env.
|
||||
// Set DRY_RUN=1 to preview without writing anything.
|
||||
|
||||
import { MongoClient } from "mongodb";
|
||||
import { config } from "dotenv";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
config({ path: resolve(__dirname, "../../.env") });
|
||||
|
||||
const BG_URI = process.env.BG_MONGODB_URI;
|
||||
const GG_URI = process.env.NUXT_MONGODB_URI || process.env.MONGODB_URI;
|
||||
const DRY_RUN = process.env.DRY_RUN === "1";
|
||||
|
||||
if (!BG_URI) {
|
||||
console.error("ERROR: BG_MONGODB_URI is not set.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!GG_URI) {
|
||||
console.error(
|
||||
"ERROR: No ghostguild MongoDB URI found (NUXT_MONGODB_URI or MONGODB_URI).",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log("=== Importing babyghosts pre-registrations into ghostguild ===");
|
||||
if (DRY_RUN) console.log("DRY RUN – nothing will be written.\n");
|
||||
|
||||
const bgClient = new MongoClient(BG_URI);
|
||||
const ggClient = new MongoClient(GG_URI);
|
||||
|
||||
await bgClient.connect();
|
||||
await ggClient.connect();
|
||||
|
||||
try {
|
||||
const source = bgClient.db("babyghosts").collection("guild_waitlist");
|
||||
const dest = ggClient.db().collection("preregistrations");
|
||||
|
||||
const records = await source.find({}).toArray();
|
||||
console.log(
|
||||
`Found ${records.length} record(s) in babyghosts.guild_waitlist.\n`,
|
||||
);
|
||||
|
||||
if (records.length === 0) {
|
||||
console.log("Nothing to import.");
|
||||
return;
|
||||
}
|
||||
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const doc of records) {
|
||||
const email = doc.email?.trim().toLowerCase();
|
||||
|
||||
if (!email) {
|
||||
console.warn(` SKIP – record ${doc._id} has no email.`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log(` [dry-run] ${email}`);
|
||||
inserted++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await dest.updateOne(
|
||||
{ email },
|
||||
{ $setOnInsert: doc },
|
||||
{ upsert: true },
|
||||
);
|
||||
|
||||
if (result.upsertedCount > 0) {
|
||||
console.log(` INSERT ${email}`);
|
||||
inserted++;
|
||||
} else {
|
||||
console.log(` EXISTS ${email} (skipped)`);
|
||||
skipped++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(` ERROR ${email}: ${err.message}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n=== Summary ===");
|
||||
console.log(` Inserted : ${inserted}`);
|
||||
console.log(` Skipped : ${skipped}`);
|
||||
console.log(` Errors : ${errors}`);
|
||||
} finally {
|
||||
await bgClient.close();
|
||||
await ggClient.close();
|
||||
console.log("\nDone.");
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error("Unexpected error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -11,6 +11,12 @@ const getValidContributionValues = () => ["0", "5", "15", "30", "50"];
|
|||
|
||||
const memberSchema = new mongoose.Schema({
|
||||
email: { type: String, required: true, unique: true },
|
||||
emailHistory: [
|
||||
{
|
||||
email: { type: String, required: true },
|
||||
changedAt: { type: Date, default: Date.now },
|
||||
},
|
||||
],
|
||||
name: { type: String, required: true },
|
||||
circle: {
|
||||
type: String,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue