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
|
.fleet
|
||||||
.idea
|
.idea
|
||||||
/docs/
|
/docs/
|
||||||
*.md/
|
/*.md
|
||||||
|
|
||||||
# Local env files
|
# Local env files
|
||||||
.env
|
.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)
|
* Brygada 1918: Display/heading serif
|
||||||
* Commit Mono: Body/UI monospace (Google Fonts)
|
* 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 {
|
:root {
|
||||||
--bg: #f4efe4;
|
--bg: #f4efe4;
|
||||||
|
--input-bg: #faf8f2;
|
||||||
--surface: #e8dfc8;
|
--surface: #e8dfc8;
|
||||||
--surface-hover: #e0d6bc;
|
--surface-hover: #e0d6bc;
|
||||||
--border: #b8a880;
|
--border: #b8a880;
|
||||||
|
|
@ -36,10 +37,12 @@
|
||||||
--c-practitioner: #2a4650;
|
--c-practitioner: #2a4650;
|
||||||
--green: #4a6a38;
|
--green: #4a6a38;
|
||||||
--green-bg: rgba(74, 106, 56, 0.08);
|
--green-bg: rgba(74, 106, 56, 0.08);
|
||||||
|
--ember-bg: rgba(138, 68, 32, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--bg: #131210;
|
--bg: #131210;
|
||||||
|
--input-bg: #1c1a17;
|
||||||
--surface: #1a1815;
|
--surface: #1a1815;
|
||||||
--surface-hover: #252220;
|
--surface-hover: #252220;
|
||||||
--border: #2a2520;
|
--border: #2a2520;
|
||||||
|
|
@ -59,16 +62,17 @@
|
||||||
--c-community: #a06850;
|
--c-community: #a06850;
|
||||||
--c-founder: #c06030;
|
--c-founder: #c06030;
|
||||||
--c-practitioner: #4a7080;
|
--c-practitioner: #4a7080;
|
||||||
|
--ember-bg: rgba(192, 96, 48, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- TAILWIND @THEME MAPPING ---- */
|
/* ---- TAILWIND @THEME MAPPING ---- */
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: 'Commit Mono', monospace;
|
--font-sans: "Commit Mono", monospace;
|
||||||
--font-body: 'Commit Mono', monospace;
|
--font-body: "Commit Mono", monospace;
|
||||||
--font-mono: 'Commit Mono', monospace;
|
--font-mono: "Commit Mono", monospace;
|
||||||
--font-display: 'Brygada 1918', serif;
|
--font-display: "Brygada 1918", serif;
|
||||||
--font-serif: 'Brygada 1918', serif;
|
--font-serif: "Brygada 1918", serif;
|
||||||
|
|
||||||
/* Map primary to candle for Nuxt UI components */
|
/* Map primary to candle for Nuxt UI components */
|
||||||
--color-primary-500: var(--candle);
|
--color-primary-500: var(--candle);
|
||||||
|
|
@ -81,14 +85,30 @@
|
||||||
body {
|
body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: 'Commit Mono', monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
a { color: var(--candle); text-decoration: none; }
|
/* ---- NOISE TEXTURE OVERLAY ---- */
|
||||||
a:hover { text-decoration: underline; }
|
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 LABELS ---- */
|
||||||
.section-label {
|
.section-label {
|
||||||
|
|
@ -108,14 +128,26 @@ a:hover { text-decoration: underline; }
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border: 1px dashed;
|
border: 1px dashed;
|
||||||
}
|
}
|
||||||
.badge.community { color: var(--c-community); border-color: rgba(122, 72, 56, 0.35); }
|
.badge.community {
|
||||||
.badge.founder { color: var(--c-founder); border-color: rgba(138, 68, 32, 0.35); }
|
color: var(--c-community);
|
||||||
.badge.practitioner { color: var(--c-practitioner); border-color: rgba(42, 70, 80, 0.35); }
|
border-color: rgba(122, 72, 56, 0.35);
|
||||||
.badge.all { color: var(--text-dim); border-color: var(--border); }
|
}
|
||||||
|
.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 ---- */
|
/* ---- BUTTONS ---- */
|
||||||
.btn {
|
.btn {
|
||||||
font-family: 'Commit Mono', monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 7px 18px;
|
padding: 7px 18px;
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
|
|
@ -125,14 +157,20 @@ a:hover { text-decoration: underline; }
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
transition: all 0.15s;
|
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 {
|
.btn-primary {
|
||||||
background: var(--candle);
|
background: var(--candle);
|
||||||
color: var(--bg);
|
color: var(--bg);
|
||||||
border-color: var(--candle);
|
border-color: var(--candle);
|
||||||
border-style: solid;
|
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 {
|
.btn-danger {
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
border-color: var(--ember);
|
border-color: var(--ember);
|
||||||
|
|
@ -144,7 +182,9 @@ a:hover { text-decoration: underline; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- FORM FIELDS ---- */
|
/* ---- FORM FIELDS ---- */
|
||||||
.field { margin-bottom: 12px; }
|
.field {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
.field label {
|
.field label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
|
|
@ -153,17 +193,21 @@ a:hover { text-decoration: underline; }
|
||||||
margin-bottom: 3px;
|
margin-bottom: 3px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.field input, .field select, .field textarea {
|
.field input,
|
||||||
|
.field select,
|
||||||
|
.field textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
font-family: 'Commit Mono', monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
background: var(--bg);
|
background: var(--input-bg);
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
outline: none;
|
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-color: var(--candle);
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
}
|
}
|
||||||
|
|
@ -174,8 +218,25 @@ a:hover { text-decoration: underline; }
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
.dashed-box:hover { border-color: var(--candle-faint); }
|
.dashed-box:hover {
|
||||||
.dashed-box.no-hover:hover { border-color: var(--border); }
|
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 DIVIDERS ---- */
|
||||||
.section-divider {
|
.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"
|
:to="item.path"
|
||||||
:class="{ active: isActive(item.path) }"
|
:class="{ active: isActive(item.path) }"
|
||||||
@click="handleNavigate"
|
@click="handleNavigate"
|
||||||
>{{ item.label }}</NuxtLink>
|
>{{ item.label }}</NuxtLink
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#" class="sign-out" @click.prevent="handleLogout"
|
||||||
|
>Sign out</a
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|
@ -28,18 +34,8 @@
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
:class="{ active: isActive(item.path) }"
|
:class="{ active: isActive(item.path) }"
|
||||||
@click="handleNavigate"
|
@click="handleNavigate"
|
||||||
>{{ item.label }}</NuxtLink>
|
>{{ 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>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -53,7 +49,8 @@
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
:class="{ active: isActive(item.path) }"
|
:class="{ active: isActive(item.path) }"
|
||||||
@click="handleNavigate"
|
@click="handleNavigate"
|
||||||
>{{ item.label }}</NuxtLink>
|
>{{ item.label }}</NuxtLink
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|
@ -64,7 +61,8 @@
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
:class="{ active: isActive(item.path) }"
|
:class="{ active: isActive(item.path) }"
|
||||||
@click="handleNavigate"
|
@click="handleNavigate"
|
||||||
>{{ item.label }}</NuxtLink>
|
>{{ item.label }}</NuxtLink
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -78,7 +76,8 @@
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
:class="{ active: isActive(item.path) }"
|
:class="{ active: isActive(item.path) }"
|
||||||
@click="handleNavigate"
|
@click="handleNavigate"
|
||||||
>{{ item.label }}</NuxtLink>
|
>{{ item.label }}</NuxtLink
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|
@ -89,7 +88,8 @@
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
:class="{ active: isActive(item.path) }"
|
:class="{ active: isActive(item.path) }"
|
||||||
@click="handleNavigate"
|
@click="handleNavigate"
|
||||||
>{{ item.label }}</NuxtLink>
|
>{{ item.label }}</NuxtLink
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -99,29 +99,18 @@
|
||||||
<!-- Meta at bottom -->
|
<!-- Meta at bottom -->
|
||||||
<div class="sidebar-meta">
|
<div class="sidebar-meta">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<template v-if="isAuthenticated">
|
Part of
|
||||||
<span class="member-name">{{ memberData?.name || 'Member' }}</span><br>
|
<a href="https://babyghosts.fund" target="_blank">Baby Ghosts</a><br />
|
||||||
<span
|
A Canadian nonprofit
|
||||||
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>
|
|
||||||
|
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
Part of <a href="https://babyghosts.fund" target="_blank">Baby Ghosts</a><br>
|
Part of
|
||||||
A Canadian nonprofit<br>
|
<a href="https://babyghosts.fund" target="_blank">Baby Ghosts</a
|
||||||
<a href="#" @click.prevent="openLogin">Sign in</a>
|
><br />
|
||||||
|
A Canadian nonprofit
|
||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
|
<DevLoginPanel v-if="isDev" />
|
||||||
<ColorModeToggle />
|
<ColorModeToggle />
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -134,68 +123,59 @@ const props = defineProps({
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['navigate'])
|
const emit = defineEmits(["navigate"]);
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute();
|
||||||
const { isAuthenticated, logout, memberData } = useAuth()
|
const { isAuthenticated, logout } = useAuth();
|
||||||
const { openLoginModal } = useLoginModal()
|
const isDev = import.meta.dev;
|
||||||
|
|
||||||
const handleNavigate = () => {
|
const handleNavigate = () => {
|
||||||
if (props.isMobile) {
|
if (props.isMobile) {
|
||||||
emit('navigate')
|
emit("navigate");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout()
|
await logout();
|
||||||
handleNavigate()
|
handleNavigate();
|
||||||
}
|
navigateTo("/");
|
||||||
|
};
|
||||||
const openLogin = () => {
|
|
||||||
openLoginModal()
|
|
||||||
handleNavigate()
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActive = (path) => {
|
const isActive = (path) => {
|
||||||
if (path === '/') return route.path === '/'
|
if (path === "/") return route.path === "/";
|
||||||
return route.path.startsWith(path)
|
return route.path.startsWith(path);
|
||||||
}
|
};
|
||||||
|
|
||||||
// Public nav items
|
// Public nav items
|
||||||
const publicItems = [
|
const publicItems = [
|
||||||
{ label: 'Home', path: '/' },
|
{ label: "Home", path: "/" },
|
||||||
{ label: 'About', path: '/about' },
|
{ label: "About", path: "/about" },
|
||||||
{ label: 'Events', path: '/events' },
|
{ label: "Events", path: "/events" },
|
||||||
{ label: 'Members', path: '/members' },
|
{ label: "Members", path: "/members" },
|
||||||
{ label: 'Wiki', path: '/wiki' },
|
{ label: "Wiki", path: "https://wiki.ghostguild.org" },
|
||||||
]
|
];
|
||||||
|
|
||||||
const joinItems = [
|
const joinItems = [
|
||||||
{ label: 'Become a member', path: '/join' },
|
{ label: "Become a member", path: "/join" },
|
||||||
{ label: 'Propose an event', path: '/events' },
|
{ label: "Propose an event", path: "/events" },
|
||||||
]
|
];
|
||||||
|
|
||||||
// Logged-in nav items
|
// Logged-in nav items
|
||||||
const youItems = [
|
const youItems = [
|
||||||
{ label: 'Dashboard', path: '/member/dashboard' },
|
{ label: "Dashboard", path: "/member/dashboard" },
|
||||||
{ label: 'Profile', path: '/member/profile' },
|
{ label: "Profile", path: "/member/profile" },
|
||||||
{ label: 'Account', path: '/member/account' },
|
{ label: "Account", path: "/member/account" },
|
||||||
{ label: 'My Updates', path: '/member/my-updates' },
|
{ label: "My Updates", path: "/member/my-updates" },
|
||||||
]
|
];
|
||||||
|
|
||||||
const exploreItems = [
|
const exploreItems = [
|
||||||
{ label: 'Events', path: '/events' },
|
{ label: "Events", path: "/events" },
|
||||||
{ label: 'Members', path: '/members' },
|
{ label: "Members", path: "/members" },
|
||||||
{ label: 'Wiki', path: '/wiki' },
|
{ label: "Wiki", path: "/wiki" },
|
||||||
{ label: 'About', path: '/about' },
|
{ label: "About", path: "/about" },
|
||||||
]
|
];
|
||||||
|
|
||||||
const communityItems = [
|
|
||||||
{ label: 'Peer Support', path: '/members' },
|
|
||||||
{ label: 'Propose an Event', path: '/events' },
|
|
||||||
]
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -221,12 +201,14 @@ const communityItems = [
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-brand {
|
.sidebar-brand {
|
||||||
display: block;
|
display: flex;
|
||||||
font-family: 'Brygada 1918', serif;
|
align-items: center;
|
||||||
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
padding: 24px 24px 16px;
|
padding: 0 24px;
|
||||||
|
height: 53px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
@ -237,6 +219,7 @@ const communityItems = [
|
||||||
.sidebar-body {
|
.sidebar-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
padding-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-section {
|
.sidebar-section {
|
||||||
|
|
@ -267,6 +250,11 @@ const communityItems = [
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-nav a.sign-out {
|
||||||
|
color: var(--text-faint);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-nav a:hover {
|
.sidebar-nav a:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
|
@ -290,15 +278,4 @@ const communityItems = [
|
||||||
.sidebar-meta a {
|
.sidebar-meta a {
|
||||||
color: var(--candle-dim);
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,24 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="color-mode-toggle">
|
<div class="color-mode-toggle segmented">
|
||||||
<button
|
<button
|
||||||
v-for="option in options"
|
v-for="option in options"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
:class="{ active: colorMode.preference === option.value }"
|
:class="{ active: colorMode.preference === option.value }"
|
||||||
@click="colorMode.preference = option.value"
|
@click="colorMode.preference = option.value"
|
||||||
>{{ option.label }}</button>
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const colorMode = useColorMode()
|
const colorMode = useColorMode();
|
||||||
|
|
||||||
const options = [
|
const options = [
|
||||||
{ label: 'Light', value: 'light' },
|
{ label: "Light", value: "light" },
|
||||||
{ label: 'System', value: 'system' },
|
{ label: "System", value: "system" },
|
||||||
{ label: 'Dark', value: 'dark' },
|
{ label: "Dark", value: "dark" },
|
||||||
]
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -28,7 +30,7 @@ const options = [
|
||||||
.color-mode-toggle button {
|
.color-mode-toggle button {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
font-family: 'Commit Mono', monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
@ -36,10 +38,12 @@ const options = [
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Overlap adjacent borders so dashed lines collapse into one */
|
||||||
.color-mode-toggle button + button {
|
.color-mode-toggle button + button {
|
||||||
border-left: none;
|
margin-left: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-mode-toggle button:hover {
|
.color-mode-toggle button:hover {
|
||||||
|
|
@ -51,13 +55,6 @@ const options = [
|
||||||
border-color: var(--candle);
|
border-color: var(--candle);
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
z-index: 1;
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
}
|
||||||
</style>
|
</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-if="events?.length" class="em-rows">
|
||||||
<div v-for="event in events" :key="event._id" class="em-item">
|
<div v-for="event in events" :key="event._id" class="em-item">
|
||||||
<div class="em-inset em-item-body">
|
<div class="em-inset em-item-body">
|
||||||
<span class="em-date">{{ formatDate(event.date) }}</span>
|
<span class="em-date">{{ formatDate(event.startDate) }}</span>
|
||||||
<NuxtLink :to="`/events/${event._id}`" class="em-title">{{ event.title }}</NuxtLink>
|
<NuxtLink :to="`/events/${event._id}`" class="em-title">{{
|
||||||
|
event.title
|
||||||
|
}}</NuxtLink>
|
||||||
<span
|
<span
|
||||||
v-if="event.circle"
|
v-if="event.circle"
|
||||||
class="em-circle"
|
class="em-circle"
|
||||||
:style="{ color: `var(--c-${event.circle})` }"
|
:style="{ color: `var(--c-${event.circle})` }"
|
||||||
>{{ event.circle }}</span>
|
>{{ event.circle }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -30,13 +33,13 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
defineProps({
|
||||||
events: { type: Array, default: () => [] },
|
events: { type: Array, default: () => [] },
|
||||||
})
|
});
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
if (!dateStr) return ''
|
if (!dateStr) return "";
|
||||||
const d = new Date(dateStr)
|
const d = new Date(dateStr);
|
||||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,31 @@
|
||||||
<template>
|
<template>
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<div v-if="shouldShowBanner" class="w-full">
|
<div v-if="shouldShowBanner" class="status-banner">
|
||||||
<div
|
<div class="status-banner-inner">
|
||||||
:class="[
|
<div class="status-banner-text">
|
||||||
'backdrop-blur-sm border rounded-lg p-4 flex items-start gap-4',
|
<strong class="status-banner-label">{{ statusConfig.label }}</strong>
|
||||||
statusConfig.bgColor,
|
<span class="status-banner-msg">{{ bannerMessage }}</span>
|
||||||
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>
|
</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 -->
|
<!-- Payment button for pending payment status -->
|
||||||
<UButton
|
<button
|
||||||
v-if="isPendingPayment && nextAction"
|
v-if="isPendingPayment"
|
||||||
:color="getButtonColor(nextAction.color)"
|
:disabled="isProcessingPayment"
|
||||||
size="sm"
|
class="btn btn-primary"
|
||||||
:loading="isProcessingPayment"
|
|
||||||
@click="handleActionClick"
|
@click="handleActionClick"
|
||||||
class="whitespace-nowrap"
|
|
||||||
>
|
>
|
||||||
{{ isProcessingPayment ? "Processing..." : nextAction.label }}
|
{{ isProcessingPayment ? "Processing..." : nextAction.label }}
|
||||||
</UButton>
|
</button>
|
||||||
|
|
||||||
<!-- Link button for other actions -->
|
<!-- Link button for other actions -->
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-else-if="nextAction && nextAction.link"
|
v-else-if="nextAction.link"
|
||||||
:to="nextAction.link"
|
:to="nextAction.link"
|
||||||
:class="[
|
class="btn"
|
||||||
'px-4 py-2 rounded-lg font-medium text-sm whitespace-nowrap transition-all',
|
|
||||||
getActionButtonClass(nextAction.color),
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
{{ nextAction.label }}
|
{{ nextAction.label }}
|
||||||
</NuxtLink>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -62,17 +33,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const props = defineProps({
|
|
||||||
dismissible: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
compact: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isPendingPayment,
|
isPendingPayment,
|
||||||
isSuspended,
|
isSuspended,
|
||||||
|
|
@ -81,11 +41,9 @@ const {
|
||||||
getNextAction,
|
getNextAction,
|
||||||
getBannerMessage,
|
getBannerMessage,
|
||||||
} = useMemberStatus();
|
} = useMemberStatus();
|
||||||
|
|
||||||
const { completePayment, isProcessingPayment } = useMemberPayment();
|
const { completePayment, isProcessingPayment } = useMemberPayment();
|
||||||
|
|
||||||
const isDismissed = ref(false);
|
|
||||||
|
|
||||||
// Handle action button click
|
|
||||||
const handleActionClick = async () => {
|
const handleActionClick = async () => {
|
||||||
if (isPendingPayment.value) {
|
if (isPendingPayment.value) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -96,33 +54,57 @@ const handleActionClick = async () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map color names to UButton color props
|
const shouldShowBanner = computed(
|
||||||
const getButtonColor = (color) => {
|
() => isPendingPayment.value || isSuspended.value || isCancelled.value,
|
||||||
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 bannerMessage = computed(() => getBannerMessage());
|
const bannerMessage = computed(() => getBannerMessage());
|
||||||
const nextAction = computed(() => getNextAction());
|
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>
|
</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>
|
<template>
|
||||||
<div class="priv">
|
<div class="priv segmented">
|
||||||
<span
|
<span
|
||||||
v-for="opt in options"
|
v-for="opt in options"
|
||||||
:key="opt.value"
|
:key="opt.value"
|
||||||
:class="{ on: modelValue === opt.value }"
|
:class="{ on: modelValue === opt.value }"
|
||||||
@click="$emit('update:modelValue', opt.value)"
|
@click="$emit('update:modelValue', opt.value)"
|
||||||
>{{ opt.label }}</span>
|
>{{ opt.label }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
defineProps({
|
||||||
modelValue: { type: String, default: 'public' },
|
modelValue: { type: String, default: "public" },
|
||||||
})
|
});
|
||||||
|
|
||||||
defineEmits(['update:modelValue'])
|
defineEmits(["update:modelValue"]);
|
||||||
|
|
||||||
const options = [
|
const options = [
|
||||||
{ label: 'Public', value: 'public' },
|
{ label: "Public", value: "public" },
|
||||||
{ label: 'Members', value: 'members' },
|
{ label: "Members", value: "members" },
|
||||||
{ label: 'Private', value: 'private' },
|
{ label: "Private", value: "private" },
|
||||||
]
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -28,7 +29,7 @@ const options = [
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-family: 'Commit Mono', monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,10 +45,11 @@ const options = [
|
||||||
transition: all 0.12s;
|
transition: all 0.12s;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.priv span + span {
|
.priv span + span {
|
||||||
border-left: none;
|
margin-left: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.priv span:hover {
|
.priv span:hover {
|
||||||
|
|
@ -59,9 +61,6 @@ const options = [
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
border-color: var(--candle);
|
border-color: var(--candle);
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
}
|
z-index: 1;
|
||||||
|
|
||||||
.priv span.on + span {
|
|
||||||
border-left-color: var(--candle);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -17,37 +17,37 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: Array, default: () => [] },
|
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 input = ref(null);
|
||||||
const newTag = ref('')
|
const newTag = ref("");
|
||||||
|
|
||||||
const focusInput = () => {
|
const focusInput = () => {
|
||||||
input.value?.focus()
|
input.value?.focus();
|
||||||
}
|
};
|
||||||
|
|
||||||
const addTag = () => {
|
const addTag = () => {
|
||||||
const tag = newTag.value.trim()
|
const tag = newTag.value.trim();
|
||||||
if (tag && !props.modelValue.includes(tag)) {
|
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 removeTag = (index) => {
|
||||||
const tags = [...props.modelValue]
|
const tags = [...props.modelValue];
|
||||||
tags.splice(index, 1)
|
tags.splice(index, 1);
|
||||||
emit('update:modelValue', tags)
|
emit("update:modelValue", tags);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleBackspace = () => {
|
const handleBackspace = () => {
|
||||||
if (!newTag.value && props.modelValue.length) {
|
if (!newTag.value && props.modelValue.length) {
|
||||||
removeTag(props.modelValue.length - 1)
|
removeTag(props.modelValue.length - 1);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -57,7 +57,7 @@ const handleBackspace = () => {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
background: var(--bg);
|
background: var(--input-bg);
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
cursor: text;
|
cursor: text;
|
||||||
|
|
@ -95,7 +95,7 @@ const handleBackspace = () => {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 1px 4px;
|
padding: 1px 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: 'Commit Mono', monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
|
|
|
||||||
|
|
@ -19,16 +19,16 @@ defineProps({
|
||||||
tiers: {
|
tiers: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [
|
default: () => [
|
||||||
{ amount: 0, display: '$0', label: 'Free' },
|
{ amount: 0, display: "$0", label: "Free" },
|
||||||
{ amount: 5, display: '$5', label: '/month' },
|
{ amount: 5, display: "$5", label: "/month" },
|
||||||
{ amount: 15, display: '$15', label: '/month' },
|
{ amount: 15, display: "$15", label: "/month" },
|
||||||
{ amount: 30, display: '$30', label: '/month' },
|
{ amount: 30, display: "$30", label: "/month" },
|
||||||
{ amount: 50, display: '$50', label: '/month' },
|
{ amount: 50, display: "$50", label: "/month" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
defineEmits(['update:modelValue'])
|
defineEmits(["update:modelValue"]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -46,27 +46,31 @@ defineEmits(['update:modelValue'])
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Overlap adjacent borders so dashed lines collapse into one */
|
||||||
.tier-option + .tier-option {
|
.tier-option + .tier-option {
|
||||||
border-left: none;
|
margin-left: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tier-option:hover {
|
.tier-option:hover {
|
||||||
background: var(--surface-hover);
|
background: var(--surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Active item paints its solid border on top of any neighbor */
|
||||||
.tier-option.current {
|
.tier-option.current {
|
||||||
border-color: var(--candle);
|
border-color: var(--candle);
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tier-amount {
|
.tier-amount {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: "Brygada 1918", serif;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,60 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="top-strip">
|
<div class="top-strip">
|
||||||
<span>
|
<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>
|
||||||
<span>
|
<span>
|
||||||
<slot name="right">
|
<slot name="right">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<template v-if="memberData">
|
<template v-if="memberData">
|
||||||
Signed in as {{ memberData.name }}
|
<NuxtLink to="/member/profile" class="member-link">
|
||||||
<template v-if="memberData.circle">
|
<img
|
||||||
· {{ memberData.circle }}
|
v-if="memberData.avatar"
|
||||||
</template>
|
:src="`/ghosties/Ghost-${capitalize(memberData.avatar)}.png`"
|
||||||
</template>
|
:alt="memberData.name"
|
||||||
<template v-else>
|
class="member-avatar"
|
||||||
A cooperative for game developers
|
/>
|
||||||
</template>
|
<svg
|
||||||
<template #fallback>
|
v-else
|
||||||
A cooperative for game developers
|
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>
|
||||||
|
<template v-else> A cooperative for game developers </template>
|
||||||
|
<template #fallback> A cooperative for game developers </template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</slot>
|
</slot>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -25,16 +62,32 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
pagePath: { type: String, default: '' },
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.top-strip {
|
.top-strip {
|
||||||
padding: 16px 32px;
|
padding: 0 32px;
|
||||||
|
min-height: 53px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
|
|
@ -42,6 +95,39 @@ const { memberData } = useAuth()
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.top-strip a { color: var(--text-faint); }
|
.top-strip a {
|
||||||
.top-strip a:hover { color: var(--candle); }
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -4,67 +4,115 @@
|
||||||
<div class="about-hero">
|
<div class="about-hero">
|
||||||
<div class="about-hero-left">
|
<div class="about-hero-left">
|
||||||
<h1>About Ghost Guild</h1>
|
<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>
|
||||||
<div class="about-hero-right">
|
<div class="about-hero-right">
|
||||||
<div class="section-label">Our Story</div>
|
<div class="section-label">Our Story</div>
|
||||||
<p>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>
|
||||||
<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>
|
Ghost Guild grew out of Baby Ghosts, a Canadian nonprofit that's been
|
||||||
<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>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
|
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
|
||||||
<div class="content-area">
|
<div class="content-area">
|
||||||
<div class="content-main">
|
<div class="content-main">
|
||||||
|
|
||||||
<!-- THE CIRCLES -->
|
<!-- THE CIRCLES -->
|
||||||
<div class="about-section" id="circles">
|
<div class="about-section" id="circles">
|
||||||
<div class="section-label">The Circles</div>
|
<div class="section-label">The Circles</div>
|
||||||
<div class="circles-grid">
|
<div class="circles-grid">
|
||||||
<div id="community" class="circle-cell">
|
<div id="community" class="circle-cell">
|
||||||
<h3 style="color: var(--c-community);">Community</h3>
|
<h3 style="color: var(--c-community)">Community</h3>
|
||||||
<div class="circle-subtitle">"The open hall"</div>
|
<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>
|
||||||
<div id="founder" class="circle-cell">
|
<div id="founder" class="circle-cell">
|
||||||
<h3 style="color: var(--c-founder);">Founder</h3>
|
<h3 style="color: var(--c-founder)">Founder</h3>
|
||||||
<div class="circle-subtitle">"The workshop"</div>
|
<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>
|
||||||
<div id="practitioner" class="circle-cell">
|
<div id="practitioner" class="circle-cell">
|
||||||
<h3 style="color: var(--c-practitioner);">Practitioner</h3>
|
<h3 style="color: var(--c-practitioner)">Practitioner</h3>
|
||||||
<div class="circle-subtitle">"The alcove"</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- HOW CONTRIBUTION WORKS -->
|
<!-- TWO-COL: CONTRIBUTION + COMMUNITY -->
|
||||||
|
<div class="two-col-row">
|
||||||
<div class="about-section">
|
<div class="about-section">
|
||||||
<div class="section-label">How Contribution Works</div>
|
<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>
|
<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">
|
<ul class="tier-list">
|
||||||
<li><span class="tier-amt">$0</span> I need support right now</li>
|
<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">$5</span> I can contribute</li>
|
||||||
<li><span class="tier-amt">$15</span> I can sustain the community</li>
|
<li>
|
||||||
<li><span class="tier-amt">$30</span> I can support others too</li>
|
<span class="tier-amt">$15</span> I can sustain the community
|
||||||
<li><span class="tier-amt">$50</span> I want to sponsor multiple members</li>
|
</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- COMMUNITY -->
|
|
||||||
<div class="about-section">
|
<div class="about-section">
|
||||||
<div class="section-label">Community</div>
|
<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>
|
<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>
|
<NuxtLink to="/join" class="cta">Join the Guild →</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ABOUT BABY GHOSTS -->
|
<!-- ABOUT BABY GHOSTS -->
|
||||||
<div class="about-section">
|
<div class="about-section">
|
||||||
<div class="section-label">About Baby Ghosts</div>
|
<div class="section-label">About Baby Ghosts</div>
|
||||||
<p>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>
|
||||||
<p><a href="https://babyghosts.fund" target="_blank">babyghosts.fund →</a></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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -75,10 +123,10 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const { data: upcomingEvents } = await useFetch('/api/events', {
|
const { data: upcomingEvents } = await useFetch("/api/events", {
|
||||||
query: { limit: 3, upcoming: true },
|
query: { limit: 3, upcoming: true },
|
||||||
default: () => [],
|
default: () => [],
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -104,7 +152,7 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
.about-hero-left h1 {
|
.about-hero-left h1 {
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
|
|
@ -176,9 +224,11 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-right: 1px dashed var(--border);
|
border-right: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
.circle-cell:last-child { border-right: none; }
|
.circle-cell:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
.circle-cell h3 {
|
.circle-cell h3 {
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
|
@ -196,6 +246,19 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
|
||||||
line-height: 1.65;
|
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 ---- */
|
||||||
.tier-list {
|
.tier-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
@ -209,7 +272,9 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.tier-list li:last-child { border-bottom: none; }
|
.tier-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
.tier-amt {
|
.tier-amt {
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -218,13 +283,26 @@ const { data: upcomingEvents } = await useFetch('/api/events', {
|
||||||
|
|
||||||
/* ---- RESPONSIVE ---- */
|
/* ---- RESPONSIVE ---- */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.content-area { grid-template-columns: 1fr; }
|
.content-area {
|
||||||
.circles-grid { grid-template-columns: 1fr; }
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.circles-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.circle-cell {
|
.circle-cell {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 1px dashed var(--border);
|
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) {
|
@media (max-width: 768px) {
|
||||||
.about-hero {
|
.about-hero {
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,18 @@
|
||||||
<div>
|
<div>
|
||||||
<!-- HERO -->
|
<!-- HERO -->
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<h1>Ghost Guild is where game developers practice cooperative business models.</h1>
|
<h1>
|
||||||
<p>Resources, events, and a community of people figuring it out. Three circles, no hierarchy. $0–50/mo, pay what you can.</p>
|
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">
|
<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="/wiki" class="hero-link">Read the wiki</NuxtLink>
|
||||||
<NuxtLink to="/about" class="hero-link">What is this?</NuxtLink>
|
<NuxtLink to="/about" class="hero-link">What is this?</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -13,8 +21,14 @@
|
||||||
|
|
||||||
<!-- THREE CIRCLES -->
|
<!-- THREE CIRCLES -->
|
||||||
<div class="content-row">
|
<div class="content-row">
|
||||||
<div v-for="circle in circleData" :key="circle.value" class="content-block">
|
<div
|
||||||
<div class="label" :style="{ color: `var(--c-${circle.value})` }">{{ circle.label }}</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>
|
<h2>{{ circle.metaphor }}</h2>
|
||||||
<p>{{ circle.blurb }}</p>
|
<p>{{ circle.blurb }}</p>
|
||||||
<details>
|
<details>
|
||||||
|
|
@ -33,9 +47,11 @@
|
||||||
<div v-if="events?.length" class="event-list">
|
<div v-if="events?.length" class="event-list">
|
||||||
<div v-for="event in events" :key="event._id" class="event-item">
|
<div v-for="event in events" :key="event._id" class="event-item">
|
||||||
<div class="block-inset event-item-inner">
|
<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">
|
<span class="event-title">
|
||||||
<NuxtLink :to="`/events/${event._id}`">{{ event.title }}</NuxtLink>
|
<NuxtLink :to="`/events/${event._id}`">{{
|
||||||
|
event.title
|
||||||
|
}}</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
<CircleBadge v-if="event.circle" :circle="event.circle" />
|
<CircleBadge v-if="event.circle" :circle="event.circle" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -76,10 +92,23 @@
|
||||||
|
|
||||||
<!-- PARCHMENT INSET -->
|
<!-- PARCHMENT INSET -->
|
||||||
<ParchmentInset>
|
<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>
|
<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>
|
||||||
<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>
|
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>
|
<p><a href="/wiki">Read more in the wiki →</a></p>
|
||||||
</ParchmentInset>
|
</ParchmentInset>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -88,42 +117,48 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "default",
|
layout: "default",
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: events } = await useFetch('/api/events', {
|
const { data: events } = await useFetch("/api/events", {
|
||||||
query: { limit: 4, upcoming: true },
|
query: { limit: 4, upcoming: true },
|
||||||
default: () => [],
|
default: () => [],
|
||||||
})
|
});
|
||||||
|
|
||||||
const circleData = [
|
const circleData = [
|
||||||
{
|
{
|
||||||
value: 'community',
|
value: "community",
|
||||||
label: 'Community',
|
label: "Community",
|
||||||
metaphor: 'The open hall',
|
metaphor: "The open hall",
|
||||||
blurb: 'Arrival, curiosity, orientation. For anyone exploring cooperative models in game development. Access the wiki, public events, and Slack.',
|
blurb:
|
||||||
included: 'Wiki access, public events, Slack community, monthly guild meetings. Free or pay-what-you-can.',
|
"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',
|
value: "founder",
|
||||||
label: 'Founder',
|
label: "Founder",
|
||||||
metaphor: 'The workshop',
|
metaphor: "The workshop",
|
||||||
blurb: 'For people actively building cooperatives. Structured practice, peer support, templates, and hands-on resources.',
|
blurb:
|
||||||
included: 'Everything in Community plus the peer accelerator, 1:1 mentorship matching, and Founder-only workshops.',
|
"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',
|
value: "practitioner",
|
||||||
label: 'Practitioner',
|
label: "Practitioner",
|
||||||
metaphor: 'The alcove',
|
metaphor: "The alcove",
|
||||||
blurb: 'Where experience is shared and knowledge given back. Teaching, advising, shaping the program itself.',
|
blurb:
|
||||||
included: 'Everything in Founder plus the ability to mentor, propose events, contribute to the wiki, and help govern the Guild.',
|
"Where experience is shared and knowledge given back. 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) => {
|
const formatDate = (dateStr) => {
|
||||||
if (!dateStr) return ''
|
if (!dateStr) return "";
|
||||||
const d = new Date(dateStr)
|
const d = new Date(dateStr);
|
||||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -133,7 +168,7 @@ const formatDate = (dateStr) => {
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
.hero h1 {
|
.hero h1 {
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 36px;
|
font-size: 36px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
|
|
@ -200,9 +235,11 @@ const formatDate = (dateStr) => {
|
||||||
padding-left: 28px;
|
padding-left: 28px;
|
||||||
padding-right: 28px;
|
padding-right: 28px;
|
||||||
}
|
}
|
||||||
.content-block:last-child { border-right: none; }
|
.content-block:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
.content-block h2 {
|
.content-block h2 {
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
|
|
@ -232,10 +269,10 @@ details summary {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
details summary::before {
|
details summary::before {
|
||||||
content: '+ ';
|
content: "+ ";
|
||||||
}
|
}
|
||||||
details[open] summary::before {
|
details[open] summary::before {
|
||||||
content: '− ';
|
content: "− ";
|
||||||
}
|
}
|
||||||
details p {
|
details p {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
|
@ -250,7 +287,7 @@ details p {
|
||||||
}
|
}
|
||||||
.event-item-inner {
|
.event-item-inner {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 80px 1fr auto;
|
grid-template-columns: 60px 1fr auto;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
|
|
@ -260,10 +297,21 @@ details p {
|
||||||
.content-row.two-col .event-item:hover .event-item-inner {
|
.content-row.two-col .event-item:hover .event-item-inner {
|
||||||
padding-left: calc(28px + 4px);
|
padding-left: calc(28px + 4px);
|
||||||
}
|
}
|
||||||
.event-date { color: var(--text-faint); font-size: 12px; }
|
.event-date {
|
||||||
.event-title { color: var(--text); font-size: 13px; }
|
color: var(--text-faint);
|
||||||
.event-title a { color: var(--text); text-decoration: none; }
|
font-size: 12px;
|
||||||
.event-title a:hover { color: var(--candle); }
|
}
|
||||||
|
.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 LIST ---- */
|
||||||
.wiki-item {
|
.wiki-item {
|
||||||
|
|
@ -277,8 +325,13 @@ details p {
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
.wiki-item a { color: var(--text); text-decoration: none; }
|
.wiki-item a {
|
||||||
.wiki-item a:hover { color: var(--candle); }
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.wiki-item a:hover {
|
||||||
|
color: var(--candle);
|
||||||
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
|
|
@ -295,7 +348,9 @@ details p {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
.content-block:last-child { border-bottom: none; }
|
.content-block:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
.hero-links {
|
.hero-links {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,25 @@
|
||||||
<!-- Unauthenticated -->
|
<!-- Unauthenticated -->
|
||||||
<div v-if="!memberData" class="loading">
|
<div v-if="!memberData" class="loading">
|
||||||
<p>Please sign in to access your account settings.</p>
|
<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>
|
||||||
|
|
||||||
<div v-else class="account-authenticated">
|
<div v-else class="account-authenticated">
|
||||||
<!-- PAGE HEADER -->
|
<!-- 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 -->
|
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
|
||||||
<div class="content-area">
|
<div class="content-area">
|
||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
<div class="account-columns">
|
<div class="account-columns">
|
||||||
|
|
||||||
<!-- LEFT COLUMN: Membership Status & Email -->
|
<!-- LEFT COLUMN: Membership Status & Email -->
|
||||||
<div class="account-col-left">
|
<div class="account-col-left">
|
||||||
<section class="account-section">
|
<section class="account-section">
|
||||||
|
|
@ -24,24 +31,42 @@
|
||||||
<div class="membership-card">
|
<div class="membership-card">
|
||||||
<div class="membership-row">
|
<div class="membership-row">
|
||||||
<span class="membership-k">Status</span>
|
<span class="membership-k">Status</span>
|
||||||
<span class="membership-v">
|
<span class="membership-v status-v">
|
||||||
<span class="status-dot" :class="memberData.status || 'active'"></span>
|
<span
|
||||||
{{ memberData.status || 'Active' }}
|
class="status-dot"
|
||||||
|
:class="memberData.status || 'active'"
|
||||||
|
></span>
|
||||||
|
<span>{{
|
||||||
|
formatStatus(memberData.status || "active")
|
||||||
|
}}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="membership-row">
|
<div class="membership-row">
|
||||||
<span class="membership-k">Circle</span>
|
<span class="membership-k">Circle</span>
|
||||||
<span class="membership-v" :style="{ color: `var(--c-${memberData.circle || 'community'})` }">
|
<span
|
||||||
{{ memberData.circle || 'Community' }}
|
class="membership-v"
|
||||||
|
:style="{
|
||||||
|
color: `var(--c-${memberData.circle || 'community'})`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
memberData.circle
|
||||||
|
? capitalise(memberData.circle)
|
||||||
|
: "Community"
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="membership-row">
|
<div class="membership-row">
|
||||||
<span class="membership-k">Contribution</span>
|
<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>
|
||||||
<div class="membership-row">
|
<div class="membership-row">
|
||||||
<span class="membership-k">Member since</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -50,10 +75,50 @@
|
||||||
<section class="account-section">
|
<section class="account-section">
|
||||||
<div class="account-col-inset">
|
<div class="account-col-inset">
|
||||||
<div class="section-label">Email</div>
|
<div class="section-label">Email</div>
|
||||||
<div class="email-display">
|
|
||||||
|
<div v-if="!showEmailEdit" class="email-display">
|
||||||
<span class="email-value">{{ memberData.email }}</span>
|
<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>
|
||||||
<div class="email-hint">Used for login magic links and notifications</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -61,17 +126,34 @@
|
||||||
<div class="account-col-inset">
|
<div class="account-col-inset">
|
||||||
<div class="section-label danger">Danger Zone</div>
|
<div class="section-label danger">Danger Zone</div>
|
||||||
<div class="danger-zone">
|
<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">
|
<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">
|
<div class="cancel-confirm-actions">
|
||||||
<button class="btn btn-danger" @click="confirmCancelMembership" :disabled="isCancelling">
|
<button
|
||||||
{{ isCancelling ? 'Cancelling...' : 'Yes, Cancel' }}
|
class="btn btn-danger"
|
||||||
|
@click="confirmCancelMembership"
|
||||||
|
:disabled="isCancelling"
|
||||||
|
>
|
||||||
|
{{ isCancelling ? "Cancelling…" : "Yes, Cancel" }}
|
||||||
|
</button>
|
||||||
|
<button class="btn" @click="showCancelConfirm = false">
|
||||||
|
Nevermind
|
||||||
</button>
|
</button>
|
||||||
<button class="btn" @click="showCancelConfirm = false">Nevermind</button>
|
|
||||||
</div>
|
</div>
|
||||||
</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
|
Cancel Membership
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -86,13 +168,18 @@
|
||||||
<div class="section-label">Change Contribution</div>
|
<div class="section-label">Change Contribution</div>
|
||||||
|
|
||||||
<TierPicker v-model="selectedTier" :tiers="tiers" />
|
<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
|
<button
|
||||||
class="btn btn-primary btn-section"
|
class="btn btn-primary btn-section"
|
||||||
@click="handleUpdateTier"
|
@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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -101,13 +188,18 @@
|
||||||
<div class="account-col-inset">
|
<div class="account-col-inset">
|
||||||
<div class="section-label">Change Circle</div>
|
<div class="section-label">Change Circle</div>
|
||||||
|
|
||||||
<CirclePicker v-model="selectedCircle" :circles="circleOptions" />
|
<CirclePicker
|
||||||
|
v-model="selectedCircle"
|
||||||
|
:circles="circleOptions"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary btn-section"
|
class="btn btn-primary btn-section"
|
||||||
@click="handleUpdateCircle"
|
@click="handleUpdateCircle"
|
||||||
:disabled="selectedCircle === memberData.circle || isUpdating"
|
:disabled="
|
||||||
|
selectedCircle === memberData.circle || isUpdating
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ isUpdating ? 'Updating...' : 'Update Circle' }}
|
{{ isUpdating ? "Updating…" : "Update Circle" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -124,107 +216,187 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: 'auth',
|
middleware: "auth",
|
||||||
})
|
});
|
||||||
|
|
||||||
const { memberData, checkMemberStatus } = useAuth()
|
const { memberData, checkMemberStatus } = useAuth();
|
||||||
const { openLoginModal } = useLoginModal()
|
const { openLoginModal } = useLoginModal();
|
||||||
const toast = useToast()
|
const toast = useToast();
|
||||||
|
|
||||||
const selectedTier = ref(0)
|
const selectedTier = ref(0);
|
||||||
const selectedCircle = ref('')
|
const selectedCircle = ref("");
|
||||||
const isUpdating = ref(false)
|
const isUpdating = ref(false);
|
||||||
const isCancelling = ref(false)
|
const isCancelling = ref(false);
|
||||||
|
|
||||||
|
// Email edit state
|
||||||
|
const showEmailEdit = ref(false);
|
||||||
|
const newEmail = ref("");
|
||||||
|
const isUpdatingEmail = ref(false);
|
||||||
|
|
||||||
const tiers = [
|
const tiers = [
|
||||||
{ amount: 0, display: '$0', label: 'Solidarity' },
|
{ amount: 0, display: "$0", label: "Solidarity" },
|
||||||
{ amount: 5, display: '$5', label: 'Supporter' },
|
{ amount: 5, display: "$5", label: "Supporter" },
|
||||||
{ amount: 15, display: '$15', label: 'Sustainer' },
|
{ amount: 15, display: "$15", label: "Sustainer" },
|
||||||
{ amount: 30, display: '$30', label: 'Builder' },
|
{ amount: 30, display: "$30", label: "Builder" },
|
||||||
{ amount: 50, display: '$50', label: 'Champion' },
|
{ amount: 50, display: "$50", label: "Champion" },
|
||||||
]
|
];
|
||||||
|
|
||||||
const circleOptions = [
|
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: "community",
|
||||||
{ value: 'practitioner', label: 'Practitioner', description: 'For professionals advising co-ops: lawyers, accountants, facilitators, consultants.' },
|
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
|
// Initialize from member data
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (memberData.value) {
|
if (memberData.value) {
|
||||||
selectedTier.value = Number(memberData.value.contributionTier || 0)
|
selectedTier.value = Number(memberData.value.contributionTier || 0);
|
||||||
selectedCircle.value = memberData.value.circle || 'community'
|
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 },
|
query: { limit: 3, upcoming: true },
|
||||||
default: () => [],
|
default: () => [],
|
||||||
})
|
});
|
||||||
|
|
||||||
const formatMemberSince = (dateStr) => {
|
const formatMemberSince = (dateStr) => {
|
||||||
if (!dateStr) return ''
|
if (!dateStr) return "";
|
||||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||||
}
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpdateTier = async () => {
|
const handleUpdateTier = async () => {
|
||||||
isUpdating.value = true
|
isUpdating.value = true;
|
||||||
try {
|
try {
|
||||||
await $fetch('/api/members/update-contribution', {
|
await $fetch("/api/members/update-contribution", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: { contributionTier: String(selectedTier.value) },
|
body: { contributionTier: String(selectedTier.value) },
|
||||||
})
|
});
|
||||||
await checkMemberStatus()
|
await checkMemberStatus();
|
||||||
toast.add({ title: 'Contribution updated', color: 'green' })
|
toast.add({ title: "Contribution updated", color: "green" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
selectedTier.value = Number(memberData.value?.contributionTier || 0)
|
selectedTier.value = Number(memberData.value?.contributionTier || 0);
|
||||||
toast.add({ title: 'Update failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' })
|
toast.add({
|
||||||
|
title: "Update failed",
|
||||||
|
description: err.data?.statusMessage || "Please try again.",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
isUpdating.value = false
|
isUpdating.value = false;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpdateCircle = async () => {
|
const handleUpdateCircle = async () => {
|
||||||
isUpdating.value = true
|
isUpdating.value = true;
|
||||||
try {
|
try {
|
||||||
await $fetch('/api/members/update-circle', {
|
await $fetch("/api/members/update-circle", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: { circle: selectedCircle.value },
|
body: { circle: selectedCircle.value },
|
||||||
})
|
});
|
||||||
await checkMemberStatus()
|
await checkMemberStatus();
|
||||||
toast.add({ title: 'Circle updated', color: 'green' })
|
toast.add({ title: "Circle updated", color: "green" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
selectedCircle.value = memberData.value?.circle || 'community'
|
selectedCircle.value = memberData.value?.circle || "community";
|
||||||
toast.add({ title: 'Update failed', description: err.data?.statusMessage || 'Please try again.', color: 'red' })
|
toast.add({
|
||||||
|
title: "Update failed",
|
||||||
|
description: err.data?.statusMessage || "Please try again.",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
} finally {
|
} 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 = () => {
|
const handleCancelMembership = () => {
|
||||||
showCancelConfirm.value = true
|
showCancelConfirm.value = true;
|
||||||
}
|
};
|
||||||
|
|
||||||
const confirmCancelMembership = async () => {
|
const confirmCancelMembership = async () => {
|
||||||
showCancelConfirm.value = false
|
showCancelConfirm.value = false;
|
||||||
isCancelling.value = true
|
isCancelling.value = true;
|
||||||
try {
|
try {
|
||||||
const result = await $fetch('/api/members/cancel-subscription', { method: 'POST' })
|
const result = await $fetch("/api/members/cancel-subscription", {
|
||||||
await checkMemberStatus()
|
method: "POST",
|
||||||
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' })
|
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 {
|
} else {
|
||||||
toast.add({ title: 'Membership cancelled', color: 'orange' })
|
toast.add({ title: "Membership cancelled", color: "orange" });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
isCancelling.value = false
|
isCancelling.value = false;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -286,18 +458,19 @@ const confirmCancelMembership = async () => {
|
||||||
border-right: 1px dashed var(--border);
|
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 {
|
.account-section {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.account-section + .account-section {
|
.account-section + .account-section {
|
||||||
margin-top: 20px;
|
margin-top: 24px;
|
||||||
border-top: 1px dashed var(--border);
|
border-top: 1px dashed var(--border);
|
||||||
padding-top: 14px;
|
padding-top: 20px;
|
||||||
}
|
}
|
||||||
.account-section + .account-section.account-section--danger {
|
.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,
|
.account-col-left > .account-section:first-child .account-col-inset,
|
||||||
|
|
@ -330,7 +503,7 @@ const confirmCancelMembership = async () => {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 120px 1fr;
|
grid-template-columns: 120px 1fr;
|
||||||
gap: 0 12px;
|
gap: 0 12px;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
|
|
@ -345,42 +518,97 @@ const confirmCancelMembership = async () => {
|
||||||
color: var(--text);
|
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 {
|
.status-dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin-right: 4px;
|
flex-shrink: 0;
|
||||||
vertical-align: middle;
|
}
|
||||||
|
.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 ---- */
|
||||||
.email-display {
|
.email-display {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
.email-value {
|
.email-value {
|
||||||
color: var(--text);
|
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 {
|
.email-hint {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
margin-top: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- DANGER ZONE ---- */
|
/* ---- DANGER ZONE ---- */
|
||||||
.section-label.danger {
|
.account-section--danger {
|
||||||
|
background: var(--ember-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-section--danger .section-label.danger {
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
}
|
}
|
||||||
.danger-zone p {
|
.account-section--danger .danger-zone p {
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
|
|
@ -390,6 +618,7 @@ const confirmCancelMembership = async () => {
|
||||||
.cancel-confirm {
|
.cancel-confirm {
|
||||||
border: 1px dashed var(--ember);
|
border: 1px dashed var(--ember);
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
.cancel-confirm-prompt {
|
.cancel-confirm-prompt {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
@ -415,8 +644,12 @@ const confirmCancelMembership = async () => {
|
||||||
|
|
||||||
/* ---- RESPONSIVE ---- */
|
/* ---- RESPONSIVE ---- */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.content-area { grid-template-columns: 1fr; }
|
.content-area {
|
||||||
.account-columns { grid-template-columns: 1fr; }
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.account-columns {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.account-col-left {
|
.account-col-left {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,12 @@
|
||||||
<p>Please sign in to access your member dashboard.</p>
|
<p>Please sign in to access your member dashboard.</p>
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@click="openLoginModal({ title: 'Sign in to your dashboard', description: 'Enter your email to access your member dashboard' })"
|
@click="
|
||||||
|
openLoginModal({
|
||||||
|
title: 'Sign in to your dashboard',
|
||||||
|
description: 'Enter your email to access your member dashboard',
|
||||||
|
})
|
||||||
|
"
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -21,9 +26,10 @@
|
||||||
|
|
||||||
<!-- Dashboard Content -->
|
<!-- Dashboard Content -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<div class="dashboard-with-sidebar">
|
||||||
<div class="dashboard-body">
|
<div class="dashboard-body">
|
||||||
<!-- Member Status Banner -->
|
<!-- Member Status Banner -->
|
||||||
<MemberStatusBanner :dismissible="true" />
|
<MemberStatusBanner />
|
||||||
|
|
||||||
<!-- Welcome Header -->
|
<!-- Welcome Header -->
|
||||||
<div class="welcome">
|
<div class="welcome">
|
||||||
|
|
@ -50,14 +56,20 @@
|
||||||
:to="`/events/${evt.slug || evt._id}`"
|
:to="`/events/${evt.slug || evt._id}`"
|
||||||
class="event-item"
|
class="event-item"
|
||||||
>
|
>
|
||||||
<span class="event-date">{{ formatEventDate(evt.startDate) }}</span>
|
<span class="event-date">{{
|
||||||
|
formatEventDate(evt.startDate)
|
||||||
|
}}</span>
|
||||||
<span class="event-title">{{ evt.title }}</span>
|
<span class="event-title">{{ evt.title }}</span>
|
||||||
<CircleBadge v-if="evt.circle" :circle="evt.circle" />
|
<CircleBadge v-if="evt.circle" :circle="evt.circle" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<!-- Calendar subscription -->
|
<!-- Calendar subscription -->
|
||||||
<button class="calendar-btn" @click="copyCalendarLink">
|
<button class="calendar-btn" @click="copyCalendarLink">
|
||||||
{{ calendarLinkCopied ? 'Link copied!' : 'Subscribe to calendar' }}
|
{{
|
||||||
|
calendarLinkCopied
|
||||||
|
? "Link copied!"
|
||||||
|
: "Subscribe to calendar"
|
||||||
|
}}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -65,20 +77,42 @@
|
||||||
<p>You haven't registered for any upcoming events</p>
|
<p>You haven't registered for any upcoming events</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NuxtLink to="/events" class="section-link">Browse all events →</NuxtLink>
|
<NuxtLink to="/events" class="section-link"
|
||||||
|
>Browse all events →</NuxtLink
|
||||||
|
>
|
||||||
|
|
||||||
<!-- Calendar subscription instructions -->
|
<!-- Calendar subscription instructions -->
|
||||||
<div v-if="registeredEvents.length > 0 && showCalendarInstructions" class="calendar-instructions">
|
<div
|
||||||
|
v-if="registeredEvents.length > 0 && showCalendarInstructions"
|
||||||
|
class="calendar-instructions"
|
||||||
|
>
|
||||||
<div class="ci-header">
|
<div class="ci-header">
|
||||||
<strong>How to Subscribe to Your Calendar</strong>
|
<strong>How to Subscribe to Your Calendar</strong>
|
||||||
<button @click="showCalendarInstructions = false" class="ci-close">×</button>
|
<button
|
||||||
|
@click="showCalendarInstructions = false"
|
||||||
|
class="ci-close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Google Calendar:</strong> Click "+" then "From URL" then paste the link</li>
|
<li>
|
||||||
<li><strong>Apple Calendar:</strong> File then New Calendar Subscription then paste the link</li>
|
<strong>Google Calendar:</strong> Click "+" then "From
|
||||||
<li><strong>Outlook:</strong> Add Calendar then Subscribe from web then paste the link</li>
|
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>
|
</ul>
|
||||||
<p class="ci-note">Your calendar will automatically update when you register or unregister from events.</p>
|
<p class="ci-note">
|
||||||
|
Your calendar will automatically update when you register or
|
||||||
|
unregister from events.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -88,14 +122,22 @@
|
||||||
to="/members?peerSupport=true"
|
to="/members?peerSupport=true"
|
||||||
class="quick-action"
|
class="quick-action"
|
||||||
:class="{ disabled: !canPeerSupport }"
|
:class="{ disabled: !canPeerSupport }"
|
||||||
:title="!canPeerSupport ? 'Complete your membership to book peer sessions' : ''"
|
:title="
|
||||||
|
!canPeerSupport
|
||||||
|
? 'Complete your membership to book peer sessions'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
>
|
>
|
||||||
Book a peer session<span class="arrow">→</span>
|
Book a peer session<span class="arrow">→</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink to="/member/profile" class="quick-action">
|
<NuxtLink to="/member/profile" class="quick-action">
|
||||||
Update your profile<span class="arrow">→</span>
|
Update your profile<span class="arrow">→</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<a href="https://wiki.ghostguild.org" target="_blank" class="quick-action">
|
<a
|
||||||
|
href="https://wiki.ghostguild.org"
|
||||||
|
target="_blank"
|
||||||
|
class="quick-action"
|
||||||
|
>
|
||||||
Browse the wiki<span class="arrow">→</span>
|
Browse the wiki<span class="arrow">→</span>
|
||||||
</a>
|
</a>
|
||||||
<NuxtLink to="/members" class="quick-action">
|
<NuxtLink to="/members" class="quick-action">
|
||||||
|
|
@ -113,25 +155,34 @@
|
||||||
<div class="section-label">Your Membership</div>
|
<div class="section-label">Your Membership</div>
|
||||||
<div class="membership-row">
|
<div class="membership-row">
|
||||||
<span class="key">Circle</span>
|
<span class="key">Circle</span>
|
||||||
<span class="val" :style="{ color: `var(--c-${memberData?.circle || 'community'})` }">
|
<span
|
||||||
|
class="val"
|
||||||
|
:style="{
|
||||||
|
color: `var(--c-${memberData?.circle || 'community'})`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
{{ memberData?.circle }}
|
{{ memberData?.circle }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="membership-row">
|
<div class="membership-row">
|
||||||
<span class="key">Contribution</span>
|
<span class="key">Contribution</span>
|
||||||
<span class="val">${{ memberData?.contributionTier }} CAD/month</span>
|
<span class="val"
|
||||||
|
>${{ memberData?.contributionTier }} CAD/month</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="membership-row">
|
<div class="membership-row">
|
||||||
<span class="key">Status</span>
|
<span class="key">Status</span>
|
||||||
<span class="val">
|
<span class="val">
|
||||||
<span :class="isActive ? 'status-active' : ''">
|
<span :class="isActive ? 'status-active' : ''">
|
||||||
{{ isActive ? 'Active' : statusConfig.label }}
|
{{ isActive ? "Active" : statusConfig.label }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="memberData?.createdAt" class="membership-row">
|
<div v-if="memberData?.createdAt" class="membership-row">
|
||||||
<span class="key">Member since</span>
|
<span class="key">Member since</span>
|
||||||
<span class="val">{{ formatMemberSince(memberData.createdAt) }}</span>
|
<span class="val">{{
|
||||||
|
formatMemberSince(memberData.createdAt)
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/member/profile#account" class="section-link">
|
<NuxtLink to="/member/profile#account" class="section-link">
|
||||||
Change circle or contribution →
|
Change circle or contribution →
|
||||||
|
|
@ -142,7 +193,9 @@
|
||||||
<div class="section-label">Peer Support</div>
|
<div class="section-label">Peer Support</div>
|
||||||
<DashedBox>
|
<DashedBox>
|
||||||
<p class="peer-text">
|
<p class="peer-text">
|
||||||
Interested in offering peer support? Set up your profile to connect with other members who share your interests and experience.
|
Interested in offering peer support? Set up your profile to
|
||||||
|
connect with other members who share your interests and
|
||||||
|
experience.
|
||||||
</p>
|
</p>
|
||||||
<NuxtLink to="/member/profile" class="section-link">
|
<NuxtLink to="/member/profile" class="section-link">
|
||||||
Set up peer support →
|
Set up peer support →
|
||||||
|
|
@ -151,6 +204,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<EventsMiniSidebar :events="upcomingEvents" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
|
|
@ -165,7 +220,8 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const { memberData, checkMemberStatus } = useAuth();
|
const { memberData, checkMemberStatus } = useAuth();
|
||||||
const { isActive, statusConfig, isPendingPayment, canPeerSupport } = useMemberStatus();
|
const { isActive, statusConfig, isPendingPayment, canPeerSupport } =
|
||||||
|
useMemberStatus();
|
||||||
const { completePayment, isProcessingPayment } = useMemberPayment();
|
const { completePayment, isProcessingPayment } = useMemberPayment();
|
||||||
|
|
||||||
const registeredEvents = ref([]);
|
const registeredEvents = ref([]);
|
||||||
|
|
@ -173,6 +229,11 @@ const loadingEvents = ref(false);
|
||||||
const calendarLinkCopied = ref(false);
|
const calendarLinkCopied = ref(false);
|
||||||
const showCalendarInstructions = ref(false);
|
const showCalendarInstructions = ref(false);
|
||||||
|
|
||||||
|
const { data: upcomingEvents } = await useFetch("/api/events", {
|
||||||
|
query: { limit: 5, upcoming: true },
|
||||||
|
default: () => [],
|
||||||
|
});
|
||||||
|
|
||||||
// Calendar subscription URL
|
// Calendar subscription URL
|
||||||
const calendarUrl = computed(() => {
|
const calendarUrl = computed(() => {
|
||||||
const memberId = memberData.value?._id || memberData.value?.id;
|
const memberId = memberData.value?._id || memberData.value?.id;
|
||||||
|
|
@ -358,7 +419,9 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-inline {
|
.loading-inline {
|
||||||
|
|
@ -381,7 +444,7 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
.unauth-state h2 {
|
.unauth-state h2 {
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
|
|
@ -405,7 +468,7 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome h1 {
|
.welcome h1 {
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-bright);
|
color: var(--text-bright);
|
||||||
|
|
@ -421,12 +484,19 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- CONTENT GRID ---- */
|
/* ---- CONTENT GRID ---- */
|
||||||
|
.dashboard-with-sidebar {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 200px;
|
||||||
|
align-items: stretch;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-body {
|
.dashboard-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-row {
|
.content-row {
|
||||||
|
|
@ -490,7 +560,7 @@ useHead({
|
||||||
|
|
||||||
/* ---- CALENDAR BUTTON ---- */
|
/* ---- CALENDAR BUTTON ---- */
|
||||||
.calendar-btn {
|
.calendar-btn {
|
||||||
font-family: 'Commit Mono', monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--candle-dim);
|
color: var(--candle-dim);
|
||||||
background: none;
|
background: none;
|
||||||
|
|
@ -636,7 +706,7 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-active::before {
|
.status-active::before {
|
||||||
content: '';
|
content: "";
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
|
|
@ -652,7 +722,17 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- RESPONSIVE ---- */
|
/* ---- RESPONSIVE ---- */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.dashboard-with-sidebar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.dashboard-with-sidebar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.content-row {
|
.content-row {
|
||||||
grid-template-columns: 1fr;
|
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>
|
||||||
|
|
@ -7,10 +7,17 @@
|
||||||
|
|
||||||
<!-- Unauthenticated State -->
|
<!-- Unauthenticated State -->
|
||||||
<div v-else-if="!memberData" class="loading-state">
|
<div v-else-if="!memberData" class="loading-state">
|
||||||
<p style="color: var(--text-faint); margin-bottom: 12px;">Please sign in to access your profile settings.</p>
|
<p style="color: var(--text-faint); margin-bottom: 12px">
|
||||||
|
Please sign in to access your profile settings.
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@click="openLoginModal({ title: 'Sign in to your profile', description: 'Enter your email to manage your profile settings' })"
|
@click="
|
||||||
|
openLoginModal({
|
||||||
|
title: 'Sign in to your profile',
|
||||||
|
description: 'Enter your email to manage your profile settings',
|
||||||
|
})
|
||||||
|
"
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -21,46 +28,62 @@
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Edit Profile"
|
title="Edit Profile"
|
||||||
subtitle="How you appear to other members"
|
subtitle="How you appear to other members"
|
||||||
/>
|
>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="
|
||||||
|
(memberData?._id || memberData?.id) &&
|
||||||
|
memberData?.status === 'active' &&
|
||||||
|
formData.showInDirectory
|
||||||
|
"
|
||||||
|
:to="`/members/${memberData?._id || memberData?.id}`"
|
||||||
|
class="view-profile-link"
|
||||||
|
>
|
||||||
|
View my public profile →
|
||||||
|
</NuxtLink>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
<!-- TWO-COLUMN FORM -->
|
<!-- TWO-COLUMN FORM -->
|
||||||
<form class="page-content" @submit.prevent="handleSubmit">
|
<form class="page-content" @submit.prevent="handleSubmit">
|
||||||
<div class="profile-main">
|
<div class="profile-main">
|
||||||
<div class="profile-columns">
|
<div class="profile-columns">
|
||||||
|
|
||||||
<!-- ======== LEFT COLUMN ======== -->
|
<!-- ======== LEFT COLUMN ======== -->
|
||||||
<div class="profile-col-left">
|
<div class="profile-col-left">
|
||||||
|
|
||||||
<div class="profile-col-inset">
|
<div class="profile-col-inset">
|
||||||
<div class="section-label">Basics</div>
|
<div class="section-label">Basics</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Name</label>
|
<label>Name</label>
|
||||||
<input v-model="formData.name" type="text" placeholder="Your name" required />
|
<input
|
||||||
|
v-model="formData.name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Your name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row-2">
|
<div class="row-2">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="label-row">
|
|
||||||
<label>Pronouns</label>
|
<label>Pronouns</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.pronouns"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., she/her, they/them"
|
||||||
|
/>
|
||||||
<PrivacyToggle v-model="formData.pronounsPrivacy" />
|
<PrivacyToggle v-model="formData.pronounsPrivacy" />
|
||||||
</div>
|
</div>
|
||||||
<input v-model="formData.pronouns" type="text" placeholder="e.g., she/her, they/them" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="label-row">
|
|
||||||
<label>Timezone</label>
|
<label>Timezone</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.timeZone"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., America/Toronto"
|
||||||
|
/>
|
||||||
<PrivacyToggle v-model="formData.timeZonePrivacy" />
|
<PrivacyToggle v-model="formData.timeZonePrivacy" />
|
||||||
</div>
|
</div>
|
||||||
<input v-model="formData.timeZone" type="text" placeholder="e.g., America/Toronto" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="label-row">
|
|
||||||
<label>Avatar</label>
|
<label>Avatar</label>
|
||||||
<PrivacyToggle v-model="formData.avatarPrivacy" />
|
|
||||||
</div>
|
|
||||||
<div class="avatar-row">
|
<div class="avatar-row">
|
||||||
<button
|
<button
|
||||||
v-for="ghost in availableGhosts"
|
v-for="ghost in availableGhosts"
|
||||||
|
|
@ -74,6 +97,7 @@
|
||||||
<img :src="ghost.image" :alt="ghost.label" />
|
<img :src="ghost.image" :alt="ghost.label" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<PrivacyToggle v-model="formData.avatarPrivacy" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -84,28 +108,37 @@
|
||||||
|
|
||||||
<div class="row-2">
|
<div class="row-2">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="label-row">
|
|
||||||
<label>Studio / Organization</label>
|
<label>Studio / Organization</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.studio"
|
||||||
|
type="text"
|
||||||
|
placeholder="Studio name"
|
||||||
|
/>
|
||||||
<PrivacyToggle v-model="formData.studioPrivacy" />
|
<PrivacyToggle v-model="formData.studioPrivacy" />
|
||||||
</div>
|
</div>
|
||||||
<input v-model="formData.studio" type="text" placeholder="Studio name" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="label-row">
|
|
||||||
<label>Location</label>
|
<label>Location</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.location"
|
||||||
|
type="text"
|
||||||
|
placeholder="Toronto, ON"
|
||||||
|
/>
|
||||||
<PrivacyToggle v-model="formData.locationPrivacy" />
|
<PrivacyToggle v-model="formData.locationPrivacy" />
|
||||||
</div>
|
</div>
|
||||||
<input v-model="formData.location" type="text" placeholder="Toronto, ON" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="label-row">
|
|
||||||
<label>Bio</label>
|
<label>Bio</label>
|
||||||
<PrivacyToggle v-model="formData.bioPrivacy" />
|
<textarea
|
||||||
|
v-model="formData.bio"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Share your background, interests, and experience..."
|
||||||
|
maxlength="300"
|
||||||
|
></textarea>
|
||||||
|
<div class="char-count">
|
||||||
|
{{ formData.bio?.length || 0 }} / 300
|
||||||
</div>
|
</div>
|
||||||
<textarea v-model="formData.bio" rows="2" placeholder="Share your background, interests, and experience..." maxlength="300"></textarea>
|
<PrivacyToggle v-model="formData.bioPrivacy" />
|
||||||
<div class="char-count">{{ formData.bio?.length || 0 }} / 300</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -115,27 +148,37 @@
|
||||||
<div class="section-label">Skills Exchange</div>
|
<div class="section-label">Skills Exchange</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="label-row">
|
|
||||||
<label>What I Can Contribute</label>
|
<label>What I Can Contribute</label>
|
||||||
|
<TagInput
|
||||||
|
v-model="formData.offering.tags"
|
||||||
|
placeholder="add skill..."
|
||||||
|
/>
|
||||||
<PrivacyToggle v-model="formData.offeringPrivacy" />
|
<PrivacyToggle v-model="formData.offeringPrivacy" />
|
||||||
</div>
|
</div>
|
||||||
<TagInput v-model="formData.offering.tags" placeholder="add skill..." />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Details</label>
|
<label>Details</label>
|
||||||
<textarea v-model="formData.offering.text" rows="2" placeholder="e.g., I have 10+ years in Unity and love helping new devs."></textarea>
|
<textarea
|
||||||
|
v-model="formData.offering.text"
|
||||||
|
rows="3"
|
||||||
|
placeholder="e.g., I have 10+ years in Unity and love helping new devs."
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="label-row">
|
|
||||||
<label>What I'm Looking For</label>
|
<label>What I'm Looking For</label>
|
||||||
|
<TagInput
|
||||||
|
v-model="formData.lookingFor.tags"
|
||||||
|
placeholder="add topic..."
|
||||||
|
/>
|
||||||
<PrivacyToggle v-model="formData.lookingForPrivacy" />
|
<PrivacyToggle v-model="formData.lookingForPrivacy" />
|
||||||
</div>
|
</div>
|
||||||
<TagInput v-model="formData.lookingFor.tags" placeholder="add topic..." />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Details</label>
|
<label>Details</label>
|
||||||
<textarea v-model="formData.lookingFor.text" rows="2" placeholder="e.g., Seeking a business-minded co-founder for a worker co-op studio."></textarea>
|
<textarea
|
||||||
|
v-model="formData.lookingFor.text"
|
||||||
|
rows="3"
|
||||||
|
placeholder="e.g., Seeking a business-minded co-founder for a worker co-op studio."
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -148,16 +191,17 @@
|
||||||
<USwitch v-model="formData.showInDirectory" />
|
<USwitch v-model="formData.showInDirectory" />
|
||||||
<div class="toggle-label">
|
<div class="toggle-label">
|
||||||
Show in Member Directory
|
Show in Member Directory
|
||||||
<span class="toggle-sub">Your profile will appear in the public member listing</span>
|
<span class="toggle-sub"
|
||||||
|
>Your profile will appear in the public member
|
||||||
|
listing</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ======== RIGHT COLUMN ======== -->
|
<!-- ======== RIGHT COLUMN ======== -->
|
||||||
<div class="profile-col-right">
|
<div class="profile-col-right">
|
||||||
|
|
||||||
<div class="profile-col-inset">
|
<div class="profile-col-inset">
|
||||||
<div class="section-label">Peer Support</div>
|
<div class="section-label">Peer Support</div>
|
||||||
|
|
||||||
|
|
@ -165,21 +209,27 @@
|
||||||
<USwitch v-model="formData.peerSupportEnabled" />
|
<USwitch v-model="formData.peerSupportEnabled" />
|
||||||
<div class="toggle-label">
|
<div class="toggle-label">
|
||||||
Offer Peer Support
|
Offer Peer Support
|
||||||
<span class="toggle-sub">Let other members request 1:1 time with you</span>
|
<span class="toggle-sub"
|
||||||
|
>Let other members request 1:1 time with you</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="formData.peerSupportEnabled" class="peer-panel">
|
<div v-if="formData.peerSupportEnabled" class="peer-panel">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Skill-Based Topics</label>
|
<label>Skill-Based Topics</label>
|
||||||
<TagInput v-model="formData.peerSupportSkillTopics" placeholder="add topic..." />
|
<TagInput
|
||||||
|
v-model="formData.peerSupportSkillTopics"
|
||||||
|
placeholder="add topic..."
|
||||||
|
/>
|
||||||
<div v-if="suggestedSkillTopics.length" class="suggested">
|
<div v-if="suggestedSkillTopics.length" class="suggested">
|
||||||
Suggested from your offerings:
|
Suggested from your offerings:
|
||||||
<a
|
<a
|
||||||
v-for="tag in suggestedSkillTopics"
|
v-for="tag in suggestedSkillTopics"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
@click="addSuggestedSkillTopic(tag)"
|
@click="addSuggestedSkillTopic(tag)"
|
||||||
>{{ tag }}</a>
|
>{{ tag }}</a
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -190,7 +240,10 @@
|
||||||
v-for="topic in availableSupportTopics"
|
v-for="topic in availableSupportTopics"
|
||||||
:key="topic"
|
:key="topic"
|
||||||
class="checkbox-item"
|
class="checkbox-item"
|
||||||
:class="{ checked: formData.peerSupportSupportTopics.includes(topic) }"
|
:class="{
|
||||||
|
checked:
|
||||||
|
formData.peerSupportSupportTopics.includes(topic),
|
||||||
|
}"
|
||||||
@click.prevent="toggleSupportTopic(topic)"
|
@click.prevent="toggleSupportTopic(topic)"
|
||||||
>
|
>
|
||||||
<span class="cb">✓</span>
|
<span class="cb">✓</span>
|
||||||
|
|
@ -201,18 +254,33 @@
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Availability</label>
|
<label>Availability</label>
|
||||||
<textarea v-model="formData.peerSupportAvailability" rows="2" placeholder="e.g. Weekday afternoons ET"></textarea>
|
<textarea
|
||||||
|
v-model="formData.peerSupportAvailability"
|
||||||
|
rows="3"
|
||||||
|
placeholder="e.g. Weekday afternoons ET"
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Slack Handle</label>
|
<label>Slack Handle</label>
|
||||||
<input v-model="formData.peerSupportSlackUsername" type="text" placeholder="@yourslackname" />
|
<input
|
||||||
|
v-model="formData.peerSupportSlackUsername"
|
||||||
|
type="text"
|
||||||
|
placeholder="@yourslackname"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Personal Message</label>
|
<label>Personal Message</label>
|
||||||
<textarea v-model="formData.peerSupportMessage" rows="2" maxlength="200" placeholder="Brief note shown to people requesting time with you"></textarea>
|
<textarea
|
||||||
<div class="char-count">{{ formData.peerSupportMessage?.length || 0 }} / 200</div>
|
v-model="formData.peerSupportMessage"
|
||||||
|
rows="3"
|
||||||
|
maxlength="200"
|
||||||
|
placeholder="Brief note shown to people requesting time with you"
|
||||||
|
></textarea>
|
||||||
|
<div class="char-count">
|
||||||
|
{{ formData.peerSupportMessage?.length || 0 }} / 200
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -226,7 +294,9 @@
|
||||||
<USwitch v-model="formData.notifyEvents" />
|
<USwitch v-model="formData.notifyEvents" />
|
||||||
<div class="toggle-label">
|
<div class="toggle-label">
|
||||||
Event reminders
|
Event reminders
|
||||||
<span class="toggle-sub">Get notified about upcoming events</span>
|
<span class="toggle-sub"
|
||||||
|
>Get notified about upcoming events</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -234,7 +304,9 @@
|
||||||
<USwitch v-model="formData.notifyUpdates" />
|
<USwitch v-model="formData.notifyUpdates" />
|
||||||
<div class="toggle-label">
|
<div class="toggle-label">
|
||||||
Community updates
|
Community updates
|
||||||
<span class="toggle-sub">New posts from members you follow</span>
|
<span class="toggle-sub"
|
||||||
|
>New posts from members you follow</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -242,23 +314,33 @@
|
||||||
<USwitch v-model="formData.notifyPeerRequests" />
|
<USwitch v-model="formData.notifyPeerRequests" />
|
||||||
<div class="toggle-label">
|
<div class="toggle-label">
|
||||||
Peer support requests
|
Peer support requests
|
||||||
<span class="toggle-sub">When someone wants to connect</span>
|
<span class="toggle-sub"
|
||||||
|
>When someone wants to connect</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ======== SAVE BAR ======== -->
|
<!-- ======== SAVE BAR ======== -->
|
||||||
<div class="save-bar">
|
<div class="save-bar">
|
||||||
<button type="submit" class="btn btn-primary" :disabled="saving || !hasChanges">
|
<button
|
||||||
{{ saving ? 'Saving...' : 'Save Profile' }}
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="saving || !hasChanges"
|
||||||
|
>
|
||||||
|
{{ saving ? "Saving..." : "Save Profile" }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn" @click="resetForm">Reset Changes</button>
|
<button type="button" class="btn" @click="resetForm">
|
||||||
<span v-if="saveSuccess" class="save-msg save-msg-ok">Profile updated.</span>
|
Reset Changes
|
||||||
<span v-if="saveError" class="save-msg save-msg-err">{{ saveError }}</span>
|
</button>
|
||||||
|
<span v-if="saveSuccess" class="save-msg save-msg-ok"
|
||||||
|
>Profile updated.</span
|
||||||
|
>
|
||||||
|
<span v-if="saveError" class="save-msg save-msg-err">{{
|
||||||
|
saveError
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -267,194 +349,216 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const { memberData, checkMemberStatus } = useAuth()
|
const { memberData, checkMemberStatus } = useAuth();
|
||||||
const { openLoginModal } = useLoginModal()
|
const { openLoginModal } = useLoginModal();
|
||||||
|
|
||||||
// Available ghost avatars
|
// Available ghost avatars
|
||||||
const availableGhosts = [
|
const availableGhosts = [
|
||||||
{ value: 'disbelieving', label: 'Disbelieving', image: '/ghosties/Ghost-Disbelieving.png' },
|
{
|
||||||
{ value: 'double-take', label: 'Double Take', image: '/ghosties/Ghost-Double-Take.png' },
|
value: "disbelieving",
|
||||||
{ value: 'exasperated', label: 'Exasperated', image: '/ghosties/Ghost-Exasperated.png' },
|
label: "Disbelieving",
|
||||||
{ value: 'mild', label: 'Mild', image: '/ghosties/Ghost-Mild.png' },
|
image: "/ghosties/Ghost-Disbelieving.png",
|
||||||
{ value: 'sweet', label: 'Sweet', image: '/ghosties/Ghost-Sweet.png' },
|
},
|
||||||
{ value: 'wtf', label: 'WTF', image: '/ghosties/Ghost-WTF.png' },
|
{
|
||||||
]
|
value: "double-take",
|
||||||
|
label: "Double Take",
|
||||||
|
image: "/ghosties/Ghost-Double-Take.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "exasperated",
|
||||||
|
label: "Exasperated",
|
||||||
|
image: "/ghosties/Ghost-Exasperated.png",
|
||||||
|
},
|
||||||
|
{ value: "mild", label: "Mild", image: "/ghosties/Ghost-Mild.png" },
|
||||||
|
{ value: "sweet", label: "Sweet", image: "/ghosties/Ghost-Sweet.png" },
|
||||||
|
{ value: "wtf", label: "WTF", image: "/ghosties/Ghost-WTF.png" },
|
||||||
|
];
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
name: '',
|
name: "",
|
||||||
pronouns: '',
|
pronouns: "",
|
||||||
timeZone: '',
|
timeZone: "",
|
||||||
avatar: '',
|
avatar: "",
|
||||||
studio: '',
|
studio: "",
|
||||||
bio: '',
|
bio: "",
|
||||||
location: '',
|
location: "",
|
||||||
offering: { text: '', tags: [] },
|
offering: { text: "", tags: [] },
|
||||||
lookingFor: { text: '', tags: [] },
|
lookingFor: { text: "", tags: [] },
|
||||||
showInDirectory: true,
|
showInDirectory: true,
|
||||||
// Peer support
|
// Peer support
|
||||||
peerSupportEnabled: false,
|
peerSupportEnabled: false,
|
||||||
peerSupportSkillTopics: [],
|
peerSupportSkillTopics: [],
|
||||||
peerSupportSupportTopics: [],
|
peerSupportSupportTopics: [],
|
||||||
peerSupportAvailability: '',
|
peerSupportAvailability: "",
|
||||||
peerSupportMessage: '',
|
peerSupportMessage: "",
|
||||||
peerSupportSlackUsername: '',
|
peerSupportSlackUsername: "",
|
||||||
// Privacy
|
// Privacy
|
||||||
pronounsPrivacy: 'members',
|
pronounsPrivacy: "members",
|
||||||
timeZonePrivacy: 'members',
|
timeZonePrivacy: "members",
|
||||||
avatarPrivacy: 'members',
|
avatarPrivacy: "members",
|
||||||
studioPrivacy: 'members',
|
studioPrivacy: "members",
|
||||||
bioPrivacy: 'members',
|
bioPrivacy: "members",
|
||||||
locationPrivacy: 'members',
|
locationPrivacy: "members",
|
||||||
offeringPrivacy: 'members',
|
offeringPrivacy: "members",
|
||||||
lookingForPrivacy: 'members',
|
lookingForPrivacy: "members",
|
||||||
// Notifications
|
// Notifications
|
||||||
notifyEvents: true,
|
notifyEvents: true,
|
||||||
notifyUpdates: true,
|
notifyUpdates: true,
|
||||||
notifyPeerRequests: true,
|
notifyPeerRequests: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false);
|
||||||
const saving = ref(false)
|
const saving = ref(false);
|
||||||
const saveSuccess = ref(false)
|
const saveSuccess = ref(false);
|
||||||
const saveError = ref(null)
|
const saveError = ref(null);
|
||||||
const initialData = ref(null)
|
const initialData = ref(null);
|
||||||
|
|
||||||
// Available conversational support topics
|
// Available conversational support topics
|
||||||
const availableSupportTopics = [
|
const availableSupportTopics = [
|
||||||
'Co-founder relationships',
|
"Co-founder relationships",
|
||||||
'Burnout prevention',
|
"Burnout prevention",
|
||||||
'Impostor syndrome',
|
"Impostor syndrome",
|
||||||
'Work-life boundaries',
|
"Work-life boundaries",
|
||||||
'Conflict resolution',
|
"Conflict resolution",
|
||||||
'General chat & support',
|
"General chat & support",
|
||||||
]
|
];
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const hasChanges = computed(() => {
|
const hasChanges = computed(() => {
|
||||||
return JSON.stringify(formData) !== JSON.stringify(initialData.value)
|
return JSON.stringify(formData) !== JSON.stringify(initialData.value);
|
||||||
})
|
});
|
||||||
|
|
||||||
const suggestedSkillTopics = computed(() => {
|
const suggestedSkillTopics = computed(() => {
|
||||||
if (!formData.offering.tags?.length) return []
|
if (!formData.offering.tags?.length) return [];
|
||||||
return formData.offering.tags.filter(
|
return formData.offering.tags.filter(
|
||||||
(t) => !formData.peerSupportSkillTopics?.includes(t)
|
(t) => !formData.peerSupportSkillTopics?.includes(t),
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Toggle a support topic in/out of the selection
|
// Toggle a support topic in/out of the selection
|
||||||
const toggleSupportTopic = (topic) => {
|
const toggleSupportTopic = (topic) => {
|
||||||
const idx = formData.peerSupportSupportTopics.indexOf(topic)
|
const idx = formData.peerSupportSupportTopics.indexOf(topic);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
formData.peerSupportSupportTopics.splice(idx, 1)
|
formData.peerSupportSupportTopics.splice(idx, 1);
|
||||||
} else {
|
} else {
|
||||||
formData.peerSupportSupportTopics.push(topic)
|
formData.peerSupportSupportTopics.push(topic);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const addSuggestedSkillTopic = (tag) => {
|
const addSuggestedSkillTopic = (tag) => {
|
||||||
if (!Array.isArray(formData.peerSupportSkillTopics)) {
|
if (!Array.isArray(formData.peerSupportSkillTopics)) {
|
||||||
formData.peerSupportSkillTopics = []
|
formData.peerSupportSkillTopics = [];
|
||||||
}
|
}
|
||||||
if (!formData.peerSupportSkillTopics.includes(tag)) {
|
if (!formData.peerSupportSkillTopics.includes(tag)) {
|
||||||
formData.peerSupportSkillTopics.push(tag)
|
formData.peerSupportSkillTopics.push(tag);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Load member data into form
|
// Load member data into form
|
||||||
const loadProfile = () => {
|
const loadProfile = () => {
|
||||||
if (memberData.value) {
|
if (memberData.value) {
|
||||||
formData.name = memberData.value.name || ''
|
formData.name = memberData.value.name || "";
|
||||||
formData.pronouns = memberData.value.pronouns || ''
|
formData.pronouns = memberData.value.pronouns || "";
|
||||||
formData.timeZone = memberData.value.timeZone || ''
|
formData.timeZone = memberData.value.timeZone || "";
|
||||||
formData.avatar = memberData.value.avatar || ''
|
formData.avatar = memberData.value.avatar || "";
|
||||||
formData.studio = memberData.value.studio || ''
|
formData.studio = memberData.value.studio || "";
|
||||||
formData.bio = memberData.value.bio || ''
|
formData.bio = memberData.value.bio || "";
|
||||||
formData.location = memberData.value.location || ''
|
formData.location = memberData.value.location || "";
|
||||||
|
|
||||||
// Load offering (handle both old string and new object format)
|
// Load offering (handle both old string and new object format)
|
||||||
if (typeof memberData.value.offering === 'string') {
|
if (typeof memberData.value.offering === "string") {
|
||||||
formData.offering.text = memberData.value.offering
|
formData.offering.text = memberData.value.offering;
|
||||||
formData.offering.tags = []
|
formData.offering.tags = [];
|
||||||
} else if (memberData.value.offering) {
|
} else if (memberData.value.offering) {
|
||||||
formData.offering.text = memberData.value.offering?.text || ''
|
formData.offering.text = memberData.value.offering?.text || "";
|
||||||
formData.offering.tags = Array.isArray(memberData.value.offering?.tags)
|
formData.offering.tags = Array.isArray(memberData.value.offering?.tags)
|
||||||
? [...memberData.value.offering.tags]
|
? [...memberData.value.offering.tags]
|
||||||
: []
|
: [];
|
||||||
} else {
|
} else {
|
||||||
formData.offering.text = ''
|
formData.offering.text = "";
|
||||||
formData.offering.tags = []
|
formData.offering.tags = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load lookingFor (handle both old string and new object format)
|
// Load lookingFor (handle both old string and new object format)
|
||||||
if (typeof memberData.value.lookingFor === 'string') {
|
if (typeof memberData.value.lookingFor === "string") {
|
||||||
formData.lookingFor.text = memberData.value.lookingFor
|
formData.lookingFor.text = memberData.value.lookingFor;
|
||||||
formData.lookingFor.tags = []
|
formData.lookingFor.tags = [];
|
||||||
} else if (memberData.value.lookingFor) {
|
} else if (memberData.value.lookingFor) {
|
||||||
formData.lookingFor.text = memberData.value.lookingFor?.text || ''
|
formData.lookingFor.text = memberData.value.lookingFor?.text || "";
|
||||||
formData.lookingFor.tags = Array.isArray(memberData.value.lookingFor?.tags)
|
formData.lookingFor.tags = Array.isArray(
|
||||||
|
memberData.value.lookingFor?.tags,
|
||||||
|
)
|
||||||
? [...memberData.value.lookingFor.tags]
|
? [...memberData.value.lookingFor.tags]
|
||||||
: []
|
: [];
|
||||||
} else {
|
} else {
|
||||||
formData.lookingFor.text = ''
|
formData.lookingFor.text = "";
|
||||||
formData.lookingFor.tags = []
|
formData.lookingFor.tags = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
formData.showInDirectory = memberData.value.showInDirectory ?? true
|
formData.showInDirectory = memberData.value.showInDirectory ?? true;
|
||||||
|
|
||||||
// Load peer support data
|
// Load peer support data
|
||||||
if (memberData.value.peerSupport) {
|
if (memberData.value.peerSupport) {
|
||||||
formData.peerSupportEnabled = memberData.value.peerSupport.enabled || false
|
formData.peerSupportEnabled =
|
||||||
formData.peerSupportSkillTopics = Array.isArray(memberData.value.peerSupport.skillTopics)
|
memberData.value.peerSupport.enabled || false;
|
||||||
|
formData.peerSupportSkillTopics = Array.isArray(
|
||||||
|
memberData.value.peerSupport.skillTopics,
|
||||||
|
)
|
||||||
? [...memberData.value.peerSupport.skillTopics]
|
? [...memberData.value.peerSupport.skillTopics]
|
||||||
: []
|
: [];
|
||||||
formData.peerSupportSupportTopics = Array.isArray(memberData.value.peerSupport.supportTopics)
|
formData.peerSupportSupportTopics = Array.isArray(
|
||||||
|
memberData.value.peerSupport.supportTopics,
|
||||||
|
)
|
||||||
? [...memberData.value.peerSupport.supportTopics]
|
? [...memberData.value.peerSupport.supportTopics]
|
||||||
: []
|
: [];
|
||||||
formData.peerSupportAvailability = memberData.value.peerSupport.availability || ''
|
formData.peerSupportAvailability =
|
||||||
formData.peerSupportMessage = memberData.value.peerSupport.personalMessage || ''
|
memberData.value.peerSupport.availability || "";
|
||||||
formData.peerSupportSlackUsername = memberData.value.peerSupport.slackUsername || ''
|
formData.peerSupportMessage =
|
||||||
|
memberData.value.peerSupport.personalMessage || "";
|
||||||
|
formData.peerSupportSlackUsername =
|
||||||
|
memberData.value.peerSupport.slackUsername || "";
|
||||||
} else {
|
} else {
|
||||||
formData.peerSupportEnabled = false
|
formData.peerSupportEnabled = false;
|
||||||
formData.peerSupportSkillTopics = []
|
formData.peerSupportSkillTopics = [];
|
||||||
formData.peerSupportSupportTopics = []
|
formData.peerSupportSupportTopics = [];
|
||||||
formData.peerSupportAvailability = ''
|
formData.peerSupportAvailability = "";
|
||||||
formData.peerSupportMessage = ''
|
formData.peerSupportMessage = "";
|
||||||
formData.peerSupportSlackUsername = ''
|
formData.peerSupportSlackUsername = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load privacy settings (with defaults)
|
// Load privacy settings (with defaults)
|
||||||
const privacy = memberData.value.privacy || {}
|
const privacy = memberData.value.privacy || {};
|
||||||
formData.pronounsPrivacy = privacy.pronouns || 'members'
|
formData.pronounsPrivacy = privacy.pronouns || "members";
|
||||||
formData.timeZonePrivacy = privacy.timeZone || 'members'
|
formData.timeZonePrivacy = privacy.timeZone || "members";
|
||||||
formData.avatarPrivacy = privacy.avatar || 'members'
|
formData.avatarPrivacy = privacy.avatar || "members";
|
||||||
formData.studioPrivacy = privacy.studio || 'members'
|
formData.studioPrivacy = privacy.studio || "members";
|
||||||
formData.bioPrivacy = privacy.bio || 'members'
|
formData.bioPrivacy = privacy.bio || "members";
|
||||||
formData.locationPrivacy = privacy.location || 'members'
|
formData.locationPrivacy = privacy.location || "members";
|
||||||
formData.offeringPrivacy = privacy.offering || 'members'
|
formData.offeringPrivacy = privacy.offering || "members";
|
||||||
formData.lookingForPrivacy = privacy.lookingFor || 'members'
|
formData.lookingForPrivacy = privacy.lookingFor || "members";
|
||||||
|
|
||||||
// Load notification prefs
|
// Load notification prefs
|
||||||
const notifs = memberData.value.notifications || {}
|
const notifs = memberData.value.notifications || {};
|
||||||
formData.notifyEvents = notifs.events ?? true
|
formData.notifyEvents = notifs.events ?? true;
|
||||||
formData.notifyUpdates = notifs.updates ?? true
|
formData.notifyUpdates = notifs.updates ?? true;
|
||||||
formData.notifyPeerRequests = notifs.peerRequests ?? true
|
formData.notifyPeerRequests = notifs.peerRequests ?? true;
|
||||||
|
|
||||||
// Store initial state for change detection
|
// Store initial state for change detection
|
||||||
initialData.value = JSON.parse(JSON.stringify(formData))
|
initialData.value = JSON.parse(JSON.stringify(formData));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
saving.value = true
|
saving.value = true;
|
||||||
saveSuccess.value = false
|
saveSuccess.value = false;
|
||||||
saveError.value = null
|
saveError.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Save profile data
|
// Save profile data
|
||||||
await $fetch('/api/members/profile', {
|
await $fetch("/api/members/profile", {
|
||||||
method: 'PATCH',
|
method: "PATCH",
|
||||||
body: {
|
body: {
|
||||||
...formData,
|
...formData,
|
||||||
notifications: {
|
notifications: {
|
||||||
|
|
@ -463,11 +567,11 @@ const handleSubmit = async () => {
|
||||||
peerRequests: formData.notifyPeerRequests,
|
peerRequests: formData.notifyPeerRequests,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// Save peer support data separately
|
// Save peer support data separately
|
||||||
await $fetch('/api/members/me/peer-support', {
|
await $fetch("/api/members/me/peer-support", {
|
||||||
method: 'PATCH',
|
method: "PATCH",
|
||||||
body: {
|
body: {
|
||||||
enabled: formData.peerSupportEnabled,
|
enabled: formData.peerSupportEnabled,
|
||||||
skillTopics: formData.peerSupportSkillTopics,
|
skillTopics: formData.peerSupportSkillTopics,
|
||||||
|
|
@ -476,52 +580,55 @@ const handleSubmit = async () => {
|
||||||
personalMessage: formData.peerSupportMessage,
|
personalMessage: formData.peerSupportMessage,
|
||||||
slackUsername: formData.peerSupportSlackUsername,
|
slackUsername: formData.peerSupportSlackUsername,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
saveSuccess.value = true
|
saveSuccess.value = true;
|
||||||
|
|
||||||
// Refresh member data
|
// Refresh member data
|
||||||
await checkMemberStatus()
|
await checkMemberStatus();
|
||||||
loadProfile()
|
loadProfile();
|
||||||
|
|
||||||
setTimeout(() => { saveSuccess.value = false }, 3000)
|
setTimeout(() => {
|
||||||
|
saveSuccess.value = false;
|
||||||
|
}, 3000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Profile save error:', error)
|
console.error("Profile save error:", error);
|
||||||
saveError.value = error.data?.message || 'Failed to save profile. Please try again.'
|
saveError.value =
|
||||||
|
error.data?.message || "Failed to save profile. Please try again.";
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Reset form to initial state
|
// Reset form to initial state
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
loadProfile()
|
loadProfile();
|
||||||
saveSuccess.value = false
|
saveSuccess.value = false;
|
||||||
saveError.value = null
|
saveError.value = null;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Initialize on mount
|
// Initialize on mount
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!memberData.value) {
|
if (!memberData.value) {
|
||||||
loading.value = true
|
loading.value = true;
|
||||||
const isAuthenticated = await checkMemberStatus()
|
const isAuthenticated = await checkMemberStatus();
|
||||||
loading.value = false
|
loading.value = false;
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
openLoginModal({
|
openLoginModal({
|
||||||
title: 'Sign in to your profile',
|
title: "Sign in to your profile",
|
||||||
description: 'Enter your email to manage your profile settings',
|
description: "Enter your email to manage your profile settings",
|
||||||
})
|
});
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadProfile()
|
loadProfile();
|
||||||
})
|
});
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: 'Edit Profile - Ghost Guild',
|
title: "Edit Profile - Ghost Guild",
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -562,32 +669,11 @@ useHead({
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Grid + save bar: one flex child so the center rule can span both */
|
|
||||||
.profile-main {
|
.profile-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Full-height vertical rule between columns (through save bar); 1fr | 1fr grid */
|
|
||||||
.profile-main::before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1025px) {
|
|
||||||
.profile-main::before {
|
|
||||||
display: block;
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 50%;
|
|
||||||
width: 0;
|
|
||||||
border-left: 1px dashed var(--border);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- TWO-COLUMN LAYOUT ---- */
|
/* ---- TWO-COLUMN LAYOUT ---- */
|
||||||
|
|
@ -609,8 +695,10 @@ useHead({
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1025px) {
|
||||||
.profile-col-left {
|
.profile-col-left {
|
||||||
border-right: none;
|
border-right: 1px dashed var(--border);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-col-left > .profile-col-inset:first-of-type,
|
.profile-col-left > .profile-col-inset:first-of-type,
|
||||||
|
|
@ -635,16 +723,40 @@ useHead({
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- LABEL WITH PRIVACY ---- */
|
/* ---- PRIVACY TOGGLE SPACING ---- */
|
||||||
.label-row {
|
.field :deep(.priv) {
|
||||||
display: flex;
|
margin-top: 4px;
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 3px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.label-row label {
|
/* ---- FIELD LABELS (distinct from .section-label) ---- */
|
||||||
margin-bottom: 0;
|
.field label {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- SOLID INPUT BORDERS ---- */
|
||||||
|
.field input,
|
||||||
|
.field select,
|
||||||
|
.field textarea {
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field :deep(.tags) {
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- VIEW PROFILE LINK ---- */
|
||||||
|
.view-profile-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--candle);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.view-profile-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- AVATAR PICKER ---- */
|
/* ---- AVATAR PICKER ---- */
|
||||||
|
|
|
||||||
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"
|
class="filter-search"
|
||||||
placeholder="Search members..."
|
placeholder="Search members..."
|
||||||
@input="debouncedSearch"
|
@input="debouncedSearch"
|
||||||
>
|
/>
|
||||||
<select
|
<select
|
||||||
v-model="selectedCircle"
|
v-model="selectedCircle"
|
||||||
class="filter-select"
|
class="filter-select"
|
||||||
@change="loadMembers"
|
@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 }}
|
{{ opt.label }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -29,17 +33,25 @@
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="peerSupportFilter === 'true'"
|
:checked="peerSupportFilter === 'true'"
|
||||||
@change="togglePeerSupport"
|
@change="togglePeerSupport"
|
||||||
>
|
/>
|
||||||
Offering support
|
Offering support
|
||||||
</label>
|
</label>
|
||||||
<span class="filter-count">Showing {{ totalCount }} member{{ totalCount === 1 ? '' : 's' }}</span>
|
<span class="filter-count"
|
||||||
|
>Showing {{ totalCount }} member{{ totalCount === 1 ? "" : "s" }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Skills Filter -->
|
<!-- 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>
|
<span class="tag-label">Skills:</span>
|
||||||
<button
|
<button
|
||||||
v-for="skill in (availableSkills || []).slice(0, showAllSkills ? undefined : 10)"
|
v-for="skill in (availableSkills || []).slice(
|
||||||
|
0,
|
||||||
|
showAllSkills ? undefined : 10,
|
||||||
|
)"
|
||||||
:key="skill"
|
:key="skill"
|
||||||
type="button"
|
type="button"
|
||||||
class="skill-tag"
|
class="skill-tag"
|
||||||
|
|
@ -54,15 +66,23 @@
|
||||||
class="more-btn"
|
class="more-btn"
|
||||||
@click="showAllSkills = !showAllSkills"
|
@click="showAllSkills = !showAllSkills"
|
||||||
>
|
>
|
||||||
{{ showAllSkills ? 'Show less' : `+${availableSkills.length - 10} more` }}
|
{{
|
||||||
|
showAllSkills ? "Show less" : `+${availableSkills.length - 10} more`
|
||||||
|
}}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Topics Filter -->
|
<!-- 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>
|
<span class="tag-label">Topics:</span>
|
||||||
<button
|
<button
|
||||||
v-for="topic in (availableTopics || []).slice(0, showAllTopics ? undefined : 10)"
|
v-for="topic in (availableTopics || []).slice(
|
||||||
|
0,
|
||||||
|
showAllTopics ? undefined : 10,
|
||||||
|
)"
|
||||||
:key="topic"
|
:key="topic"
|
||||||
type="button"
|
type="button"
|
||||||
class="skill-tag"
|
class="skill-tag"
|
||||||
|
|
@ -77,20 +97,16 @@
|
||||||
class="more-btn"
|
class="more-btn"
|
||||||
@click="showAllTopics = !showAllTopics"
|
@click="showAllTopics = !showAllTopics"
|
||||||
>
|
>
|
||||||
{{ showAllTopics ? 'Show less' : `+${availableTopics.length - 10} more` }}
|
{{
|
||||||
|
showAllTopics ? "Show less" : `+${availableTopics.length - 10} more`
|
||||||
|
}}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Active Filters -->
|
<!-- Active Filters -->
|
||||||
<div
|
<div v-if="hasActiveFilters" class="active-filters">
|
||||||
v-if="hasActiveFilters"
|
|
||||||
class="active-filters"
|
|
||||||
>
|
|
||||||
<span class="af-label">Active filters:</span>
|
<span class="af-label">Active filters:</span>
|
||||||
<span
|
<span v-if="selectedCircle && selectedCircle !== 'all'" class="af-tag">
|
||||||
v-if="selectedCircle && selectedCircle !== 'all'"
|
|
||||||
class="af-tag"
|
|
||||||
>
|
|
||||||
{{ circleLabels[selectedCircle] }}
|
{{ circleLabels[selectedCircle] }}
|
||||||
<button type="button" @click="clearCircleFilter">×</button>
|
<button type="button" @click="clearCircleFilter">×</button>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -101,19 +117,11 @@
|
||||||
Offering Peer Support
|
Offering Peer Support
|
||||||
<button type="button" @click="clearPeerSupportFilter">×</button>
|
<button type="button" @click="clearPeerSupportFilter">×</button>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span v-for="skill in selectedSkills" :key="'s-' + skill" class="af-tag">
|
||||||
v-for="skill in selectedSkills"
|
|
||||||
:key="'s-' + skill"
|
|
||||||
class="af-tag"
|
|
||||||
>
|
|
||||||
{{ skill }}
|
{{ skill }}
|
||||||
<button type="button" @click="toggleSkill(skill)">×</button>
|
<button type="button" @click="toggleSkill(skill)">×</button>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span v-for="topic in selectedTopics" :key="'t-' + topic" class="af-tag">
|
||||||
v-for="topic in selectedTopics"
|
|
||||||
:key="'t-' + topic"
|
|
||||||
class="af-tag"
|
|
||||||
>
|
|
||||||
{{ topic }}
|
{{ topic }}
|
||||||
<button type="button" @click="toggleTopic(topic)">×</button>
|
<button type="button" @click="toggleTopic(topic)">×</button>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -134,11 +142,7 @@
|
||||||
|
|
||||||
<!-- Member Grid -->
|
<!-- Member Grid -->
|
||||||
<div v-else-if="members.length > 0" class="member-grid">
|
<div v-else-if="members.length > 0" class="member-grid">
|
||||||
<div
|
<div v-for="member in members" :key="member._id" class="member-card">
|
||||||
v-for="member in members"
|
|
||||||
:key="member._id"
|
|
||||||
class="member-card"
|
|
||||||
>
|
|
||||||
<div class="mc-head">
|
<div class="mc-head">
|
||||||
<div class="mc-avatar">
|
<div class="mc-avatar">
|
||||||
<img
|
<img
|
||||||
|
|
@ -146,17 +150,22 @@
|
||||||
:src="`/ghosties/Ghost-${member.avatar.charAt(0).toUpperCase() + member.avatar.slice(1)}.png`"
|
:src="`/ghosties/Ghost-${member.avatar.charAt(0).toUpperCase() + member.avatar.slice(1)}.png`"
|
||||||
:alt="member.name"
|
:alt="member.name"
|
||||||
class="mc-avatar-img"
|
class="mc-avatar-img"
|
||||||
>
|
/>
|
||||||
<span v-else>{{ getInitials(member.name) }}</span>
|
<span v-else>{{ getInitials(member.name) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mc-info">
|
<div class="mc-info">
|
||||||
<div class="mc-name">
|
<div class="mc-name">
|
||||||
<NuxtLink v-if="isAuthenticated" :to="`/members/${member._id}`">{{ member.name }}</NuxtLink>
|
<NuxtLink :to="`/members/${member._id}`">{{
|
||||||
<span v-else>{{ member.name }}</span>
|
member.name
|
||||||
<span v-if="member.pronouns" class="mc-pronouns">{{ member.pronouns }}</span>
|
}}</NuxtLink>
|
||||||
|
<span v-if="member.pronouns" class="mc-pronouns">{{
|
||||||
|
member.pronouns
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mc-meta">
|
<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">
|
<template v-if="member.studio">
|
||||||
<span class="sep">·</span>
|
<span class="sep">·</span>
|
||||||
{{ member.studio }}
|
{{ member.studio }}
|
||||||
|
|
@ -172,7 +181,9 @@
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div v-if="member.location || member.timeZone" class="mc-location">
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Skills tags -->
|
<!-- Skills tags -->
|
||||||
|
|
@ -185,7 +196,8 @@
|
||||||
v-for="tag in member.offering.tags"
|
v-for="tag in member.offering.tags"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
class="skill-tag"
|
class="skill-tag"
|
||||||
>{{ tag }}</span>
|
>{{ tag }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Looking for -->
|
<!-- Looking for -->
|
||||||
|
|
@ -193,12 +205,14 @@
|
||||||
v-if="member.lookingFor?.tags && member.lookingFor.tags.length > 0"
|
v-if="member.lookingFor?.tags && member.lookingFor.tags.length > 0"
|
||||||
class="mc-looking"
|
class="mc-looking"
|
||||||
>
|
>
|
||||||
Looking for: {{ member.lookingFor.tags.join(', ') }}
|
Looking for: {{ member.lookingFor.tags.join(", ") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Peer support session link -->
|
<!-- Peer support session link -->
|
||||||
<a
|
<a
|
||||||
v-if="member.peerSupport?.enabled && member.peerSupport?.slackUsername"
|
v-if="
|
||||||
|
member.peerSupport?.enabled && member.peerSupport?.slackUsername
|
||||||
|
"
|
||||||
href="#"
|
href="#"
|
||||||
class="mc-session"
|
class="mc-session"
|
||||||
@click.prevent="openSlackDM(member)"
|
@click.prevent="openSlackDM(member)"
|
||||||
|
|
@ -212,25 +226,33 @@
|
||||||
<div v-else class="empty-state">
|
<div v-else class="empty-state">
|
||||||
<p class="empty-title">No members found</p>
|
<p class="empty-title">No members found</p>
|
||||||
<p class="empty-sub">Try adjusting your search or filters</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>
|
</div>
|
||||||
|
|
||||||
<!-- Load more / count -->
|
<!-- Load more / count -->
|
||||||
<div v-if="members.length > 0" class="load-more">
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Not Authenticated Notice -->
|
<!-- Not Authenticated Notice -->
|
||||||
<div
|
<div v-if="!isAuthenticated && members.length > 0" class="auth-notice">
|
||||||
v-if="!isAuthenticated && members.length > 0"
|
|
||||||
class="auth-notice"
|
|
||||||
>
|
|
||||||
<p>Some member information is visible to members only.</p>
|
<p>Some member information is visible to members only.</p>
|
||||||
<div class="auth-actions">
|
<div class="auth-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn"
|
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
|
Log In
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -282,8 +304,8 @@ const peerSupportOptions = [
|
||||||
// Computed: has active filters
|
// Computed: has active filters
|
||||||
const hasActiveFilters = computed(() => {
|
const hasActiveFilters = computed(() => {
|
||||||
return (
|
return (
|
||||||
(selectedCircle.value && selectedCircle.value !== 'all') ||
|
(selectedCircle.value && selectedCircle.value !== "all") ||
|
||||||
(peerSupportFilter.value && peerSupportFilter.value !== 'all') ||
|
(peerSupportFilter.value && peerSupportFilter.value !== "all") ||
|
||||||
selectedSkills.value.length > 0 ||
|
selectedSkills.value.length > 0 ||
|
||||||
selectedTopics.value.length > 0
|
selectedTopics.value.length > 0
|
||||||
);
|
);
|
||||||
|
|
@ -291,11 +313,11 @@ const hasActiveFilters = computed(() => {
|
||||||
|
|
||||||
// Get initials from name
|
// Get initials from name
|
||||||
const getInitials = (name) => {
|
const getInitials = (name) => {
|
||||||
if (!name) return '?';
|
if (!name) return "?";
|
||||||
return name
|
return name
|
||||||
.split(' ')
|
.split(" ")
|
||||||
.map((w) => w[0])
|
.map((w) => w[0])
|
||||||
.join('')
|
.join("")
|
||||||
.toUpperCase()
|
.toUpperCase()
|
||||||
.slice(0, 2);
|
.slice(0, 2);
|
||||||
};
|
};
|
||||||
|
|
@ -335,7 +357,7 @@ const loadMembers = async () => {
|
||||||
|
|
||||||
// Toggle peer support checkbox
|
// Toggle peer support checkbox
|
||||||
const togglePeerSupport = (e) => {
|
const togglePeerSupport = (e) => {
|
||||||
peerSupportFilter.value = e.target.checked ? 'true' : 'all';
|
peerSupportFilter.value = e.target.checked ? "true" : "all";
|
||||||
loadMembers();
|
loadMembers();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -440,7 +462,7 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-search {
|
.filter-search {
|
||||||
font-family: 'Commit Mono', monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 5px 12px;
|
padding: 5px 12px;
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
|
|
@ -458,7 +480,7 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-select {
|
.filter-select {
|
||||||
font-family: 'Commit Mono', monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
|
|
@ -514,7 +536,7 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
.skills-bar .skill-tag {
|
.skills-bar .skill-tag {
|
||||||
font-family: 'Commit Mono', monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
|
|
@ -536,7 +558,7 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
.more-btn {
|
.more-btn {
|
||||||
font-family: 'Commit Mono', monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
background: none;
|
background: none;
|
||||||
|
|
@ -587,7 +609,7 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear-all-btn {
|
.clear-all-btn {
|
||||||
font-family: 'Commit Mono', monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--candle);
|
color: var(--candle);
|
||||||
background: none;
|
background: none;
|
||||||
|
|
@ -743,7 +765,7 @@ useHead({
|
||||||
.mc-session {
|
.mc-session {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-family: 'Commit Mono', monospace;
|
font-family: "Commit Mono", monospace;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|
@ -778,7 +800,7 @@ useHead({
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.empty-title {
|
.empty-title {
|
||||||
font-family: 'Brygada 1918', serif;
|
font-family: "Brygada 1918", serif;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
|
|
@ -9,20 +9,7 @@ export default defineNuxtConfig({
|
||||||
classSuffix: "",
|
classSuffix: "",
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
head: {
|
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",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
transpile: ["vue-cal"],
|
transpile: ["vue-cal"],
|
||||||
|
|
|
||||||
20
package-lock.json
generated
20
package-lock.json
generated
|
|
@ -8,6 +8,8 @@
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudinary/vue": "^1.13.3",
|
"@cloudinary/vue": "^1.13.3",
|
||||||
|
"@fontsource-variable/brygada-1918": "^5.2.8",
|
||||||
|
"@fontsource/commit-mono": "^5.2.5",
|
||||||
"@headlessui/vue": "^1.7.23",
|
"@headlessui/vue": "^1.7.23",
|
||||||
"@heroicons/vue": "^2.2.0",
|
"@heroicons/vue": "^2.2.0",
|
||||||
"@nuxt/eslint": "^1.9.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": {
|
"node_modules/@headlessui/vue": {
|
||||||
"version": "1.7.23",
|
"version": "1.7.23",
|
||||||
"resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz",
|
"resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudinary/vue": "^1.13.3",
|
"@cloudinary/vue": "^1.13.3",
|
||||||
|
"@fontsource-variable/brygada-1918": "^5.2.8",
|
||||||
|
"@fontsource/commit-mono": "^5.2.5",
|
||||||
"@headlessui/vue": "^1.7.23",
|
"@headlessui/vue": "^1.7.23",
|
||||||
"@heroicons/vue": "^2.2.0",
|
"@heroicons/vue": "^2.2.0",
|
||||||
"@nuxt/eslint": "^1.9.0",
|
"@nuxt/eslint": "^1.9.0",
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
const config = useRuntimeConfig(event)
|
const config = useRuntimeConfig(event)
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ memberId: member._id, email: member.email },
|
{ memberId: member._id, email: member.email, tv: member.tokenVersion },
|
||||||
config.jwtSecret,
|
config.jwtSecret,
|
||||||
{ expiresIn: '7d' }
|
{ 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 config = useRuntimeConfig(event)
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ memberId: member._id, email: member.email },
|
{ memberId: member._id, email: member.email, tv: member.tokenVersion },
|
||||||
config.jwtSecret,
|
config.jwtSecret,
|
||||||
{ expiresIn: '7d' }
|
{ 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({
|
const memberSchema = new mongoose.Schema({
|
||||||
email: { type: String, required: true, unique: true },
|
email: { type: String, required: true, unique: true },
|
||||||
|
emailHistory: [
|
||||||
|
{
|
||||||
|
email: { type: String, required: true },
|
||||||
|
changedAt: { type: Date, default: Date.now },
|
||||||
|
},
|
||||||
|
],
|
||||||
name: { type: String, required: true },
|
name: { type: String, required: true },
|
||||||
circle: {
|
circle: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue