Merge branch 'feature/page-shell-refactor'
Page Layout Simplification — Member Area Full-Bleed Pattern. Converges the member-area surface on a canonical PageShell + ColumnsLayout + PageSection vocabulary, replacing 3 ad-hoc two-column implementations and ~12 hand-rolled flex-chain blocks. PageShell owns the flex chain so individual pages can no longer break it; PageSection enforces symmetric padding so asymmetric drift is structurally impossible. Visual snapshot coverage was expanded BEFORE component changes (15 of the 26 authoritative snapshots are new in this branch), then 9 pages were migrated one commit at a time. Hydration mismatch on auth-conditional UI fixed by wrapping v-if/v-else chains in <ClientOnly>. SidebarLayout deleted. 463/463 unit tests + 26/26 visual regressions green.
1
.gitignore
vendored
|
|
@ -29,6 +29,7 @@ scripts/*.js
|
|||
# Playwright
|
||||
e2e/test-results/
|
||||
playwright-report/
|
||||
e2e/.auth/
|
||||
|
||||
# Worktrees
|
||||
.worktrees/
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@
|
|||
--green: #4a6a38;
|
||||
--green-bg: rgba(74, 106, 56, 0.08);
|
||||
--ember-bg: rgba(138, 68, 32, 0.1);
|
||||
--page-pad-x: 28px;
|
||||
--page-pad-y: 24px;
|
||||
--page-collapse: 1024px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
|
@ -65,6 +68,9 @@
|
|||
--green: #6e9c52;
|
||||
--green-bg: rgba(110, 156, 82, 0.12);
|
||||
--ember-bg: rgba(202, 106, 58, 0.14);
|
||||
--page-pad-x: 28px;
|
||||
--page-pad-y: 24px;
|
||||
--page-collapse: 1024px;
|
||||
}
|
||||
|
||||
/* ---- TAILWIND @THEME MAPPING ---- */
|
||||
|
|
|
|||
98
app/components/ColumnsLayout.vue
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<template>
|
||||
<div
|
||||
class="columns-layout"
|
||||
:class="[`columns-${cols}`, `divider-${divider}`, `collapse-${collapse}`]"
|
||||
>
|
||||
<template v-if="cols === 'events-sidebar'">
|
||||
<div class="col col-main">
|
||||
<slot />
|
||||
</div>
|
||||
<EventsMiniSidebar :events="upcomingEvents" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- cols="2": named slots only. Use <template #left> and <template #right>. -->
|
||||
<div class="col col-left">
|
||||
<slot name="left" />
|
||||
</div>
|
||||
<div class="col col-right">
|
||||
<slot name="right" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
cols: { type: String, default: '2' }, // "2" | "events-sidebar"
|
||||
divider: { type: String, default: 'dashed' }, // "dashed" | "none"
|
||||
collapse: { type: String, default: '1024' }, // "1024" | "768"
|
||||
limit: { type: Number, default: 3 },
|
||||
})
|
||||
|
||||
const upcomingEvents = ref([])
|
||||
if (props.cols === 'events-sidebar') {
|
||||
const { data } = await useFetch('/api/events', {
|
||||
query: { upcoming: true, limit: props.limit },
|
||||
default: () => [],
|
||||
})
|
||||
upcomingEvents.value = data.value || []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.columns-layout {
|
||||
display: grid;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* cols="2" */
|
||||
.columns-2 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
/* cols="events-sidebar" */
|
||||
.columns-events-sidebar {
|
||||
grid-template-columns: 1fr 200px;
|
||||
}
|
||||
|
||||
/* Ensure grid children don't overflow */
|
||||
.col {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Dashed divider: right border on the first column child */
|
||||
.divider-dashed .col:first-child,
|
||||
.divider-dashed .col-main {
|
||||
border-right: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
/* Responsive collapse at 1024px (default) */
|
||||
.collapse-1024 {
|
||||
--col-collapse: 1024px;
|
||||
}
|
||||
|
||||
/* Responsive collapse at 768px */
|
||||
.collapse-768 {
|
||||
--col-collapse: 768px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.collapse-1024 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.collapse-1024 .col:first-child,
|
||||
.collapse-1024 .col-main {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.collapse-768 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.collapse-768 .col:first-child,
|
||||
.collapse-768 .col-main {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -15,7 +15,7 @@ defineProps({
|
|||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
padding: 24px 28px 16px;
|
||||
padding: var(--page-pad-y) var(--page-pad-x) 16px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.page-header h1 {
|
||||
|
|
|
|||
23
app/components/PageSection.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<template>
|
||||
<div class="page-section" :class="`divider-${divider}`">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
divider: { type: String, default: 'none' }, // "top" | "bottom" | "none"
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-section {
|
||||
padding: var(--page-pad-x) var(--page-pad-x);
|
||||
}
|
||||
.page-section.divider-top {
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
.page-section.divider-bottom {
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
</style>
|
||||
24
app/components/PageShell.vue
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<template>
|
||||
<component :is="as" class="page-shell">
|
||||
<PageHeader v-if="title" :title="title" :subtitle="subtitle" />
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
subtitle: { type: String, default: '' },
|
||||
as: { type: String, default: 'div' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
<template>
|
||||
<div class="sidebar-layout">
|
||||
<div class="sidebar-layout-main">
|
||||
<slot />
|
||||
</div>
|
||||
<EventsMiniSidebar :events="upcomingEvents" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
limit: { type: Number, default: 3 },
|
||||
})
|
||||
|
||||
const { data: upcomingEvents } = await useFetch('/api/events', {
|
||||
query: { limit: props.limit, upcoming: true },
|
||||
default: () => [],
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-layout {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 200px;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar-layout-main {
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
Now let me apply this to each page. Let me update all four in parallel:
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="about-page">
|
||||
<PageShell>
|
||||
<!-- ABOUT HERO (side by side) -->
|
||||
<div class="about-hero">
|
||||
<div class="about-hero-left">
|
||||
|
|
@ -31,7 +31,7 @@
|
|||
</div>
|
||||
|
||||
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
|
||||
<SidebarLayout>
|
||||
<ColumnsLayout cols="events-sidebar" :limit="3">
|
||||
<!-- THE CIRCLES -->
|
||||
<div class="about-section" id="circles">
|
||||
<div class="section-label">The Circles</div>
|
||||
|
|
@ -111,21 +111,13 @@
|
|||
>
|
||||
</p>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
</div>
|
||||
</ColumnsLayout>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup></script>
|
||||
|
||||
<style scoped>
|
||||
/* Flex chain from layout .main-body: hero + grid grow so sidebar column matches main height */
|
||||
.about-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---- ABOUT HERO ---- */
|
||||
.about-hero {
|
||||
display: grid;
|
||||
|
|
|
|||
|
|
@ -1,103 +1,108 @@
|
|||
<template>
|
||||
<div class="admin-dash">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<h1>Admin Dashboard</h1>
|
||||
<p>Members, events, and community operations</p>
|
||||
</div>
|
||||
|
||||
<PageShell
|
||||
title="Admin Dashboard"
|
||||
subtitle="Members, events, and community operations"
|
||||
>
|
||||
<AdminAlertsPanel />
|
||||
|
||||
<!-- Stats + Quick Actions row -->
|
||||
<div class="content-row">
|
||||
<div class="content-block">
|
||||
<div class="section-label">Overview</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-key">Total Members</span>
|
||||
<span class="stat-val">{{ stats.totalMembers || 0 }}</span>
|
||||
<ColumnsLayout cols="2" collapse="768" class="admin-row">
|
||||
<template #left>
|
||||
<div class="admin-block">
|
||||
<div class="section-label">Overview</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-key">Total Members</span>
|
||||
<span class="stat-val">{{ stats.totalMembers || 0 }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-key">Active Events</span>
|
||||
<span class="stat-val">{{ stats.activeEvents || 0 }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-key">Monthly Revenue</span>
|
||||
<span class="stat-val">${{ stats.monthlyRevenue || 0 }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-key">Pending Slack Invites</span>
|
||||
<span class="stat-val">{{ stats.pendingSlackInvites || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-key">Active Events</span>
|
||||
<span class="stat-val">{{ stats.activeEvents || 0 }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-key">Monthly Revenue</span>
|
||||
<span class="stat-val">${{ stats.monthlyRevenue || 0 }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-key">Pending Slack Invites</span>
|
||||
<span class="stat-val">{{ stats.pendingSlackInvites || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="content-block">
|
||||
<div class="section-label">Quick Actions</div>
|
||||
<NuxtLink to="/admin/members" class="action-link">
|
||||
Manage Members<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/events" class="action-link">
|
||||
Manage Events<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/events/create" class="action-link">
|
||||
Create Event<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/series/create" class="action-link">
|
||||
Create Series<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<template #right>
|
||||
<div class="admin-block">
|
||||
<div class="section-label">Quick Actions</div>
|
||||
<NuxtLink to="/admin/members" class="action-link">
|
||||
Manage Members<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/events" class="action-link">
|
||||
Manage Events<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/events/create" class="action-link">
|
||||
Create Event<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/admin/series/create" class="action-link">
|
||||
Create Series<span class="arrow">→</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</ColumnsLayout>
|
||||
|
||||
<!-- Recent Activity row -->
|
||||
<div class="content-row">
|
||||
<div class="content-block">
|
||||
<div class="section-label">Recent Members</div>
|
||||
<ColumnsLayout cols="2" collapse="768" class="admin-row">
|
||||
<template #left>
|
||||
<div class="admin-block">
|
||||
<div class="section-label">Recent Members</div>
|
||||
|
||||
<div v-if="pending" class="loading-inline">
|
||||
<div class="spinner spinner-sm" />
|
||||
</div>
|
||||
<div v-if="pending" class="loading-inline">
|
||||
<div class="spinner spinner-sm" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="recentMembers.length" class="item-list">
|
||||
<div v-for="member in recentMembers" :key="member._id" class="item-row">
|
||||
<div>
|
||||
<NuxtLink :to="`/admin/members/${member._id}`" class="item-name">{{ member.name }}</NuxtLink>
|
||||
<span class="item-sub">{{ member.email }}</span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<span class="badge" :class="member.circle">{{ member.circle }}</span>
|
||||
<span class="item-date">{{ formatDate(member.createdAt) }}</span>
|
||||
<div v-else-if="recentMembers.length" class="item-list">
|
||||
<div v-for="member in recentMembers" :key="member._id" class="item-row">
|
||||
<div>
|
||||
<NuxtLink :to="`/admin/members/${member._id}`" class="item-name">{{ member.name }}</NuxtLink>
|
||||
<span class="item-sub">{{ member.email }}</span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<span class="badge" :class="member.circle">{{ member.circle }}</span>
|
||||
<span class="item-date">{{ formatDate(member.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">No recent members</div>
|
||||
|
||||
<NuxtLink to="/admin/members" class="section-link">View all members →</NuxtLink>
|
||||
</div>
|
||||
<div v-else class="empty-state">No recent members</div>
|
||||
</template>
|
||||
|
||||
<NuxtLink to="/admin/members" class="section-link">View all members →</NuxtLink>
|
||||
</div>
|
||||
<template #right>
|
||||
<div class="admin-block">
|
||||
<div class="section-label">Upcoming Events</div>
|
||||
|
||||
<div class="content-block">
|
||||
<div class="section-label">Upcoming Events</div>
|
||||
<div v-if="pending" class="loading-inline">
|
||||
<div class="spinner spinner-sm" />
|
||||
</div>
|
||||
|
||||
<div v-if="pending" class="loading-inline">
|
||||
<div class="spinner spinner-sm" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="upcomingEvents.length" class="item-list">
|
||||
<div v-for="event in upcomingEvents" :key="event._id" class="item-row">
|
||||
<div>
|
||||
<span class="item-name">{{ event.title }}</span>
|
||||
<span class="item-sub">{{ formatDateTime(event.startDate) }}</span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
|
||||
<span class="item-date">{{ event.location || 'Online' }}</span>
|
||||
<div v-else-if="upcomingEvents.length" class="item-list">
|
||||
<div v-for="event in upcomingEvents" :key="event._id" class="item-row">
|
||||
<div>
|
||||
<span class="item-name">{{ event.title }}</span>
|
||||
<span class="item-sub">{{ formatDateTime(event.startDate) }}</span>
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<span class="badge" :class="event.eventType">{{ event.eventType }}</span>
|
||||
<span class="item-date">{{ event.location || 'Online' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">No upcoming events</div>
|
||||
<div v-else class="empty-state">No upcoming events</div>
|
||||
|
||||
<NuxtLink to="/admin/events" class="section-link">View all events →</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NuxtLink to="/admin/events" class="section-link">View all events →</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</ColumnsLayout>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
|
@ -127,45 +132,16 @@ const formatDateTime = (dateString) => {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-dash {}
|
||||
|
||||
/* ---- PAGE HEADER ---- */
|
||||
.page-header {
|
||||
padding: 28px 28px 20px;
|
||||
/* ---- ROWS ---- */
|
||||
.admin-row {
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
line-height: 1.2;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ---- CONTENT GRID ---- */
|
||||
.content-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.content-block {
|
||||
.admin-block {
|
||||
padding: 24px 28px;
|
||||
border-right: 1px dashed var(--border);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-block:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* ---- STATS ---- */
|
||||
.stat-row {
|
||||
display: flex;
|
||||
|
|
@ -313,24 +289,7 @@ a.item-name:hover {
|
|||
|
||||
/* ---- RESPONSIVE ---- */
|
||||
@media (max-width: 768px) {
|
||||
.content-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.content-block {
|
||||
border-right: none;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.content-block:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 24px 20px 16px;
|
||||
}
|
||||
|
||||
.content-block {
|
||||
.admin-block {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
<template>
|
||||
<div class="connections-page">
|
||||
<PageHeader
|
||||
title="Connections"
|
||||
subtitle="Find members who share your cooperative interests"
|
||||
/>
|
||||
|
||||
<PageShell
|
||||
title="Connections"
|
||||
subtitle="Find members who share your cooperative interests"
|
||||
>
|
||||
<ClientOnly>
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
|
|
@ -312,7 +310,7 @@
|
|||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<template>
|
||||
<div class="member-account-page">
|
||||
<PageShell>
|
||||
<ClientOnly>
|
||||
<!-- Unauthenticated -->
|
||||
<div v-if="!memberData" class="loading">
|
||||
<p>Please sign in to access your account settings.</p>
|
||||
|
|
@ -11,7 +12,7 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="account-authenticated">
|
||||
<template v-else>
|
||||
<!-- PAGE HEADER -->
|
||||
<PageHeader
|
||||
title="Account Settings"
|
||||
|
|
@ -19,189 +20,180 @@
|
|||
/>
|
||||
|
||||
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
|
||||
<SidebarLayout>
|
||||
<div class="account-columns">
|
||||
<ColumnsLayout cols="events-sidebar">
|
||||
<ColumnsLayout cols="2">
|
||||
<!-- LEFT COLUMN: Membership Status & Email -->
|
||||
<div class="account-col-left">
|
||||
<section class="account-section">
|
||||
<div class="account-col-inset">
|
||||
<div class="section-label">Current Membership</div>
|
||||
<template #left>
|
||||
<PageSection>
|
||||
<div class="section-label">Current Membership</div>
|
||||
|
||||
<div class="membership-card">
|
||||
<div class="membership-row">
|
||||
<span class="membership-k">Status</span>
|
||||
<span class="membership-v status-v">
|
||||
<span
|
||||
class="status-dot"
|
||||
:class="memberData.status || 'active'"
|
||||
></span>
|
||||
<span>{{
|
||||
formatStatus(memberData.status || "active")
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="membership-row">
|
||||
<span class="membership-k">Circle</span>
|
||||
<div class="membership-card">
|
||||
<div class="membership-row">
|
||||
<span class="membership-k">Status</span>
|
||||
<span class="membership-v status-v">
|
||||
<span
|
||||
class="membership-v"
|
||||
:style="{
|
||||
color: `var(--c-${memberData.circle || 'community'})`,
|
||||
}"
|
||||
>
|
||||
{{
|
||||
memberData.circle
|
||||
? capitalise(memberData.circle)
|
||||
: "Community"
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="membership-row">
|
||||
<span class="membership-k">Contribution</span>
|
||||
<span class="membership-v"
|
||||
>${{ memberData.contributionTier || 0 }} / month</span
|
||||
>
|
||||
</div>
|
||||
<div class="membership-row">
|
||||
<span class="membership-k">Member since</span>
|
||||
<span class="membership-v">{{
|
||||
formatMemberSince(memberData.createdAt)
|
||||
class="status-dot"
|
||||
:class="memberData.status || 'active'"
|
||||
></span>
|
||||
<span>{{
|
||||
formatStatus(memberData.status || "active")
|
||||
}}</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="account-section">
|
||||
<div class="account-col-inset">
|
||||
<div class="section-label">Email</div>
|
||||
|
||||
<div v-if="!showEmailEdit" class="email-display">
|
||||
<span class="email-value">{{ memberData.email }}</span>
|
||||
<button class="btn btn-inline" @click="showEmailEdit = true">
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="email-edit">
|
||||
<div class="field">
|
||||
<label>New email address</label>
|
||||
<input
|
||||
type="email"
|
||||
v-model="newEmail"
|
||||
placeholder="you@example.com"
|
||||
@keydown.enter="handleUpdateEmail"
|
||||
@keydown.escape="cancelEmailEdit"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="email-edit-actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="handleUpdateEmail"
|
||||
:disabled="isUpdatingEmail || !newEmail.trim()"
|
||||
>
|
||||
{{ isUpdatingEmail ? "Saving…" : "Save" }}
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
@click="cancelEmailEdit"
|
||||
:disabled="isUpdatingEmail"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="email-hint">
|
||||
Used for login magic links and notifications
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="account-section account-section--danger">
|
||||
<div class="account-col-inset">
|
||||
<div class="section-label danger">Danger Zone</div>
|
||||
<div class="danger-zone">
|
||||
<p>
|
||||
Cancelling your membership will immediately revoke access to
|
||||
member-only resources, events, and the Slack workspace.
|
||||
<strong>This action cannot be easily undone.</strong>
|
||||
</p>
|
||||
<div v-if="showCancelConfirm" class="cancel-confirm">
|
||||
<p class="cancel-confirm-prompt">
|
||||
Are you sure? This cannot be easily undone.
|
||||
</p>
|
||||
<div class="cancel-confirm-actions">
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
@click="confirmCancelMembership"
|
||||
:disabled="isCancelling"
|
||||
>
|
||||
{{ isCancelling ? "Cancelling…" : "Yes, Cancel" }}
|
||||
</button>
|
||||
<button class="btn" @click="showCancelConfirm = false">
|
||||
Nevermind
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-danger"
|
||||
@click="handleCancelMembership"
|
||||
:disabled="isCancelling"
|
||||
<div class="membership-row">
|
||||
<span class="membership-k">Circle</span>
|
||||
<span
|
||||
class="membership-v"
|
||||
:style="{
|
||||
color: `var(--c-${memberData.circle || 'community'})`,
|
||||
}"
|
||||
>
|
||||
Cancel Membership
|
||||
{{
|
||||
memberData.circle
|
||||
? capitalise(memberData.circle)
|
||||
: "Community"
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="membership-row">
|
||||
<span class="membership-k">Contribution</span>
|
||||
<span class="membership-v"
|
||||
>${{ memberData.contributionTier || 0 }} / month</span
|
||||
>
|
||||
</div>
|
||||
<div class="membership-row">
|
||||
<span class="membership-k">Member since</span>
|
||||
<span class="membership-v">{{
|
||||
formatMemberSince(memberData.createdAt)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
<PageSection divider="top">
|
||||
<div class="section-label">Email</div>
|
||||
|
||||
<div v-if="!showEmailEdit" class="email-display">
|
||||
<span class="email-value">{{ memberData.email }}</span>
|
||||
<button class="btn btn-inline" @click="showEmailEdit = true">
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="email-edit">
|
||||
<div class="field">
|
||||
<label>New email address</label>
|
||||
<input
|
||||
type="email"
|
||||
v-model="newEmail"
|
||||
placeholder="you@example.com"
|
||||
@keydown.enter="handleUpdateEmail"
|
||||
@keydown.escape="cancelEmailEdit"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="email-edit-actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="handleUpdateEmail"
|
||||
:disabled="isUpdatingEmail || !newEmail.trim()"
|
||||
>
|
||||
{{ isUpdatingEmail ? "Saving…" : "Save" }}
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
@click="cancelEmailEdit"
|
||||
:disabled="isUpdatingEmail"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="email-hint">
|
||||
Used for login magic links and notifications
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
<PageSection divider="top" class="danger-section">
|
||||
<div class="section-label danger">Danger Zone</div>
|
||||
<div class="danger-zone">
|
||||
<p>
|
||||
Cancelling your membership will immediately revoke access to
|
||||
member-only resources, events, and the Slack workspace.
|
||||
<strong>This action cannot be easily undone.</strong>
|
||||
</p>
|
||||
<div v-if="showCancelConfirm" class="cancel-confirm">
|
||||
<p class="cancel-confirm-prompt">
|
||||
Are you sure? This cannot be easily undone.
|
||||
</p>
|
||||
<div class="cancel-confirm-actions">
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
@click="confirmCancelMembership"
|
||||
:disabled="isCancelling"
|
||||
>
|
||||
{{ isCancelling ? "Cancelling…" : "Yes, Cancel" }}
|
||||
</button>
|
||||
<button class="btn" @click="showCancelConfirm = false">
|
||||
Nevermind
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-danger"
|
||||
@click="handleCancelMembership"
|
||||
:disabled="isCancelling"
|
||||
>
|
||||
Cancel Membership
|
||||
</button>
|
||||
</div>
|
||||
</PageSection>
|
||||
</template>
|
||||
|
||||
<!-- RIGHT COLUMN: Change Contribution & Circle -->
|
||||
<div class="account-col-right">
|
||||
<section class="account-section">
|
||||
<div class="account-col-inset">
|
||||
<div class="section-label">Change Contribution</div>
|
||||
<template #right>
|
||||
<PageSection>
|
||||
<div class="section-label">Change Contribution</div>
|
||||
|
||||
<TierPicker v-model="selectedTier" :tiers="tiers" />
|
||||
<div class="tier-hint">
|
||||
Changes take effect on your next billing cycle
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary btn-section"
|
||||
@click="handleUpdateTier"
|
||||
:disabled="
|
||||
selectedTier === Number(memberData.contributionTier || 0) ||
|
||||
isUpdating
|
||||
"
|
||||
>
|
||||
{{ isUpdating ? "Updating…" : "Update Contribution" }}
|
||||
</button>
|
||||
<TierPicker v-model="selectedTier" :tiers="tiers" />
|
||||
<div class="tier-hint">
|
||||
Changes take effect on your next billing cycle
|
||||
</div>
|
||||
</section>
|
||||
<button
|
||||
class="btn btn-primary btn-section"
|
||||
@click="handleUpdateTier"
|
||||
:disabled="
|
||||
selectedTier === Number(memberData.contributionTier || 0) ||
|
||||
isUpdating
|
||||
"
|
||||
>
|
||||
{{ isUpdating ? "Updating…" : "Update Contribution" }}
|
||||
</button>
|
||||
</PageSection>
|
||||
|
||||
<section class="account-section">
|
||||
<div class="account-col-inset">
|
||||
<div class="section-label">Change Circle</div>
|
||||
<PageSection divider="top">
|
||||
<div class="section-label">Change Circle</div>
|
||||
|
||||
<CirclePicker
|
||||
v-model="selectedCircle"
|
||||
:circles="circleOptions"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary btn-section"
|
||||
@click="handleUpdateCircle"
|
||||
:disabled="selectedCircle === memberData.circle || isUpdating"
|
||||
>
|
||||
{{ isUpdating ? "Updating…" : "Update Circle" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
</div>
|
||||
</div>
|
||||
<CirclePicker
|
||||
v-model="selectedCircle"
|
||||
:circles="circleOptions"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary btn-section"
|
||||
@click="handleUpdateCircle"
|
||||
:disabled="selectedCircle === memberData.circle || isUpdating"
|
||||
>
|
||||
{{ isUpdating ? "Updating…" : "Update Circle" }}
|
||||
</button>
|
||||
</PageSection>
|
||||
</template>
|
||||
</ColumnsLayout>
|
||||
</ColumnsLayout>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
|
@ -385,82 +377,11 @@ const confirmCancelMembership = async () => {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.member-account-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
flex: 1;
|
||||
padding: 48px 32px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.account-authenticated {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---- TWO-COLUMN LAYOUT ---- */
|
||||
.account-columns {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
}
|
||||
.account-col-left,
|
||||
.account-col-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.account-col-left {
|
||||
border-right: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
/* Full-column rules: border on block-level section */
|
||||
.account-section {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.account-section + .account-section {
|
||||
margin-top: 24px;
|
||||
border-top: 1px dashed var(--border);
|
||||
padding-top: 20px;
|
||||
}
|
||||
.account-section + .account-section.account-section--danger {
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.account-col-left > .account-section:first-child .account-col-inset,
|
||||
.account-col-right > .account-section:first-child .account-col-inset {
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.account-col-left > .account-section:last-child .account-col-inset,
|
||||
.account-col-right > .account-section:last-child .account-col-inset {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.account-col-left .account-col-inset {
|
||||
padding-left: 28px;
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
.account-col-right .account-col-inset {
|
||||
padding-left: 24px;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
/* ---- MEMBERSHIP CARD ---- */
|
||||
.membership-card {
|
||||
border: 1px dashed var(--border);
|
||||
|
|
@ -567,14 +488,14 @@ const confirmCancelMembership = async () => {
|
|||
}
|
||||
|
||||
/* ---- DANGER ZONE ---- */
|
||||
.account-section--danger {
|
||||
.danger-section {
|
||||
background: var(--ember-bg);
|
||||
}
|
||||
|
||||
.account-section--danger .section-label.danger {
|
||||
.danger-section .section-label.danger {
|
||||
color: var(--ember);
|
||||
}
|
||||
.account-section--danger .danger-zone p {
|
||||
.danger-section .danger-zone p {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
|
|
@ -610,19 +531,4 @@ const confirmCancelMembership = async () => {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---- RESPONSIVE ---- */
|
||||
@media (max-width: 1024px) {
|
||||
.account-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.account-col-left {
|
||||
border-right: none;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.account-col-left .account-col-inset,
|
||||
.account-col-right .account-col-inset {
|
||||
padding-left: 28px;
|
||||
padding-right: 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
<template>
|
||||
<div class="activity-page">
|
||||
<PageHeader
|
||||
title="Activity Log"
|
||||
subtitle="Your activity and milestones in the Guild"
|
||||
/>
|
||||
|
||||
<SidebarLayout :limit="5">
|
||||
<PageShell
|
||||
title="Activity Log"
|
||||
subtitle="Your activity and milestones in the Guild"
|
||||
>
|
||||
<ColumnsLayout cols="events-sidebar" :limit="5">
|
||||
<ClientOnly>
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading && !entries.length" class="state-box">
|
||||
|
|
@ -66,8 +64,8 @@
|
|||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</SidebarLayout>
|
||||
</div>
|
||||
</ColumnsLayout>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
|
@ -142,13 +140,6 @@ useHead({ title: 'Activity Log - Ghost Guild' })
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.activity-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---- STATE BOXES ---- */
|
||||
.state-box {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="dashboard">
|
||||
<PageShell>
|
||||
<ClientOnly>
|
||||
<!-- Loading State -->
|
||||
<div v-if="authPending" class="loading-state">
|
||||
|
|
@ -26,19 +26,17 @@
|
|||
|
||||
<!-- Dashboard Content -->
|
||||
<template v-else>
|
||||
<SidebarLayout :limit="5">
|
||||
<div class="dashboard-body">
|
||||
<!-- Member Status Banner -->
|
||||
<MemberStatusBanner />
|
||||
<ColumnsLayout cols="events-sidebar" :limit="5">
|
||||
<!-- Member Status Banner -->
|
||||
<MemberStatusBanner />
|
||||
|
||||
<!-- Welcome Header -->
|
||||
<div class="welcome">
|
||||
<h1>Welcome back, {{ memberData?.name }}</h1>
|
||||
<div class="meta">
|
||||
<PageHeader :title="`Welcome back, ${memberData?.name || ''}`">
|
||||
<div class="dashboard-meta">
|
||||
<CircleBadge :circle="memberData?.circle || 'community'" />
|
||||
<span>${{ memberData?.contributionTier }} CAD/mo</span>
|
||||
</div>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Upcoming Events + Quick Actions -->
|
||||
<div class="content-row">
|
||||
|
|
@ -203,8 +201,7 @@
|
|||
</DashedBox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
</ColumnsLayout>
|
||||
</template>
|
||||
|
||||
<template #fallback>
|
||||
|
|
@ -214,7 +211,7 @@
|
|||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
|
@ -376,15 +373,6 @@ useHead({
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ---- DASHBOARD LAYOUT ---- */
|
||||
.dashboard {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ---- LOADING / UNAUTH STATES ---- */
|
||||
.loading-state {
|
||||
flex: 1;
|
||||
|
|
@ -451,37 +439,14 @@ useHead({
|
|||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ---- WELCOME HEADER ---- */
|
||||
.welcome {
|
||||
padding: 28px 28px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.welcome h1 {
|
||||
font-family: "Brygada 1918", serif;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--text-bright);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.welcome .meta {
|
||||
/* ---- WELCOME HEADER META ---- */
|
||||
.dashboard-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.dashboard-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.content-row {
|
||||
|
|
@ -722,10 +687,6 @@ useHead({
|
|||
border-bottom: none;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.content-block {
|
||||
padding: 20px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<template>
|
||||
<div class="profile-page">
|
||||
<PageShell as="form" @submit.prevent="handleSubmit">
|
||||
<ClientOnly>
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<p style="color: var(--text-faint)">Loading your profile...</p>
|
||||
|
|
@ -11,6 +12,7 @@
|
|||
Please sign in to access your profile settings.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="
|
||||
openLoginModal({
|
||||
|
|
@ -23,7 +25,7 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="profile-authenticated">
|
||||
<template v-else>
|
||||
<!-- PAGE HEADER -->
|
||||
<PageHeader
|
||||
title="Edit Profile"
|
||||
|
|
@ -42,14 +44,12 @@
|
|||
</NuxtLink>
|
||||
</PageHeader>
|
||||
|
||||
<!-- TWO-COLUMN FORM -->
|
||||
<form class="page-content" @submit.prevent="handleSubmit">
|
||||
<div class="profile-main">
|
||||
<div class="profile-columns">
|
||||
<!-- ======== LEFT COLUMN ======== -->
|
||||
<div class="profile-col-left">
|
||||
<div class="profile-col-inset">
|
||||
<div class="section-label">Basics</div>
|
||||
<!-- TWO-COLUMN FORM BODY -->
|
||||
<ColumnsLayout cols="2">
|
||||
<!-- ======== LEFT COLUMN ======== -->
|
||||
<template #left>
|
||||
<PageSection>
|
||||
<div class="section-label">Basics</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
|
|
@ -99,12 +99,11 @@
|
|||
</div>
|
||||
<PrivacyToggle v-model="formData.avatarPrivacy" />
|
||||
</div>
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
<!-- About You -->
|
||||
<hr class="section-divider" />
|
||||
<div class="profile-col-inset">
|
||||
<div class="section-label">About You</div>
|
||||
<!-- About You -->
|
||||
<PageSection divider="top">
|
||||
<div class="section-label">About You</div>
|
||||
|
||||
<div class="row-2">
|
||||
<div class="field">
|
||||
|
|
@ -151,12 +150,11 @@
|
|||
/>
|
||||
<PrivacyToggle v-model="formData.craftTagsPrivacy" />
|
||||
</div>
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
<!-- Visibility -->
|
||||
<hr class="section-divider" />
|
||||
<div class="profile-col-inset">
|
||||
<div class="section-label">Visibility</div>
|
||||
<!-- Visibility -->
|
||||
<PageSection divider="top">
|
||||
<div class="section-label">Visibility</div>
|
||||
|
||||
<div class="toggle-field">
|
||||
<USwitch
|
||||
|
|
@ -171,13 +169,13 @@
|
|||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageSection>
|
||||
</template>
|
||||
|
||||
<!-- ======== RIGHT COLUMN ======== -->
|
||||
<div class="profile-col-right">
|
||||
<div class="profile-col-inset">
|
||||
<div class="section-label">Community Connections</div>
|
||||
<!-- ======== RIGHT COLUMN ======== -->
|
||||
<template #right>
|
||||
<PageSection>
|
||||
<div class="section-label">Community Connections</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Topics</label>
|
||||
|
|
@ -247,12 +245,11 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
<!-- Notifications -->
|
||||
<hr class="section-divider" />
|
||||
<div class="profile-col-inset">
|
||||
<div class="section-label">Notifications</div>
|
||||
<!-- Notifications -->
|
||||
<PageSection divider="top">
|
||||
<div class="section-label">Notifications</div>
|
||||
|
||||
<div class="toggle-field">
|
||||
<USwitch
|
||||
|
|
@ -292,36 +289,35 @@
|
|||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageSection>
|
||||
</template>
|
||||
</ColumnsLayout>
|
||||
|
||||
<!-- ======== SAVE BAR ======== -->
|
||||
<div class="save-bar">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="saving || !hasChanges"
|
||||
>
|
||||
{{ saving ? "Saving..." : "Save Profile" }}
|
||||
</button>
|
||||
<button type="button" class="btn" @click="resetForm">
|
||||
Reset Changes
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
<!-- ======== SAVE BAR ======== -->
|
||||
<div class="save-bar">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="saving || !hasChanges"
|
||||
>
|
||||
{{ saving ? "Saving..." : "Save Profile" }}
|
||||
</button>
|
||||
<button type="button" class="btn" @click="resetForm">
|
||||
Reset Changes
|
||||
</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>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
|
||||
<!-- Tag Suggest Modal -->
|
||||
<TagSuggestModal v-model:open="showTagSuggestModal" :pool="tagSuggestPool" />
|
||||
</div>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
|
@ -546,20 +542,6 @@ useHead({
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.profile-authenticated {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---- LOADING / EMPTY STATE ---- */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
|
|
@ -570,66 +552,6 @@ useHead({
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-page > .loading-state {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ---- CONTENT AREA ---- */
|
||||
.page-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.profile-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---- TWO-COLUMN LAYOUT ---- */
|
||||
.profile-columns {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 0;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.profile-col-left,
|
||||
.profile-col-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
@media (min-width: 1025px) {
|
||||
.profile-col-left {
|
||||
border-right: 1px dashed var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
.profile-col-left > .profile-col-inset:first-of-type,
|
||||
.profile-col-right > .profile-col-inset:first-of-type {
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.profile-col-left .profile-col-inset {
|
||||
padding-left: 28px;
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
.profile-col-right .profile-col-inset {
|
||||
padding-left: 24px;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
/* ---- MULTI-COLUMN ROWS ---- */
|
||||
.row-2 {
|
||||
display: grid;
|
||||
|
|
@ -783,37 +705,11 @@ useHead({
|
|||
}
|
||||
|
||||
/* ---- RESPONSIVE ---- */
|
||||
@media (max-width: 1024px) {
|
||||
.profile-columns {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
.profile-col-left {
|
||||
border-right: none;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.profile-col-left .profile-col-inset,
|
||||
.profile-col-right .profile-col-inset {
|
||||
padding-left: 28px;
|
||||
padding-right: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.row-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profile-col-left .profile-col-inset,
|
||||
.profile-col-right .profile-col-inset {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.save-bar {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="profile-page">
|
||||
<PageShell>
|
||||
<!-- Loading State -->
|
||||
<div v-if="pending" class="loading-state">
|
||||
<p>Loading profile...</p>
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
<!-- Profile Content -->
|
||||
<template v-else>
|
||||
|
||||
<!-- HERO: full-bleed, outside SidebarLayout -->
|
||||
<!-- HERO: full-bleed, outside ColumnsLayout -->
|
||||
<div class="profile-hero" :class="{ 'profile-hero--with-links': hasSocialLinks }">
|
||||
|
||||
<!-- Left: Avatar + Identity -->
|
||||
|
|
@ -99,8 +99,8 @@
|
|||
</div>
|
||||
<!-- END HERO -->
|
||||
|
||||
<!-- SidebarLayout wraps all remaining sections -->
|
||||
<SidebarLayout>
|
||||
<!-- ColumnsLayout wraps all remaining sections -->
|
||||
<ColumnsLayout cols="events-sidebar">
|
||||
|
||||
<!-- Bio: parch (inverted) block -->
|
||||
<div v-if="member.bio" class="profile-section profile-section--parch">
|
||||
|
|
@ -213,10 +213,10 @@
|
|||
<NuxtLink to="/members" class="back-link">← Back to Members</NuxtLink>
|
||||
</div>
|
||||
|
||||
</SidebarLayout>
|
||||
</ColumnsLayout>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
|
@ -361,19 +361,6 @@ useHead({
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ====================================================
|
||||
PROFILE PAGE
|
||||
Full-bleed layout: no max-width, no centering.
|
||||
Flex chain enables SidebarLayout's flex: 1 to work.
|
||||
==================================================== */
|
||||
|
||||
.profile-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---- LOADING STATE ---- */
|
||||
.loading-state {
|
||||
padding: 80px 32px;
|
||||
|
|
@ -527,7 +514,7 @@ useHead({
|
|||
}
|
||||
|
||||
/* ====================================================
|
||||
SECTIONS — inside SidebarLayout
|
||||
SECTIONS — inside ColumnsLayout
|
||||
==================================================== */
|
||||
|
||||
.profile-section {
|
||||
|
|
@ -747,7 +734,7 @@ useHead({
|
|||
==================================================== */
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
/* SidebarLayout sidebar hides itself at ≤1024px */
|
||||
/* ColumnsLayout events-sidebar hides itself at ≤1024px */
|
||||
.profile-two-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
<template>
|
||||
<div class="members-page">
|
||||
<!-- Page Header -->
|
||||
<PageHeader
|
||||
title="Members"
|
||||
:subtitle="`${totalCount} member${totalCount === 1 ? '' : 's'} across 3 circles`"
|
||||
/>
|
||||
|
||||
<PageShell
|
||||
title="Members"
|
||||
:subtitle="`${totalCount} member${totalCount === 1 ? '' : 's'} across 3 circles`"
|
||||
>
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar">
|
||||
<input
|
||||
|
|
@ -264,7 +261,7 @@
|
|||
<NuxtLink to="/join" class="btn btn-primary">Join Ghost Guild</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
<PageHeader
|
||||
title="Event Series"
|
||||
subtitle="Discover our multi-event series designed to take you on a journey of learning and growth"
|
||||
size="large"
|
||||
/>
|
||||
|
||||
<!-- Series Grid -->
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
<PageHeader
|
||||
title="Welcome to Ghost Guild"
|
||||
subtitle="You're officially part of the community!"
|
||||
size="large"
|
||||
/>
|
||||
|
||||
<section class="py-16 bg-guild-900">
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 323 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 293 KiB After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 249 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 297 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 254 KiB After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 333 KiB After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
|
@ -1,5 +1,7 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { loginAsAdmin } from '../helpers/auth.js'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
const viewports = {
|
||||
desktop: { width: 1280, height: 720 },
|
||||
|
|
@ -11,6 +13,9 @@ const publicPages = [
|
|||
{ name: 'join', path: '/join' },
|
||||
{ name: 'events', path: '/events' },
|
||||
{ name: 'coming-soon', path: '/coming-soon' },
|
||||
// about and members have no auth middleware — accessible publicly
|
||||
{ name: 'about', path: '/about' },
|
||||
{ name: 'members', path: '/members' },
|
||||
]
|
||||
|
||||
const authenticatedPages = [
|
||||
|
|
@ -18,8 +23,28 @@ const authenticatedPages = [
|
|||
{ name: 'member-profile', path: '/member/profile' },
|
||||
{ name: 'admin-members', path: '/admin/members' },
|
||||
{ name: 'admin-events-create', path: '/admin/events/create' },
|
||||
// New authenticated pages
|
||||
{ name: 'member-account', path: '/member/account' },
|
||||
{ name: 'member-activity', path: '/member/activity' },
|
||||
{ name: 'connections', path: '/connections' },
|
||||
{ name: 'admin-dashboard', path: '/admin' },
|
||||
]
|
||||
|
||||
// Pages that need mobile coverage captured while authenticated.
|
||||
// These cover column-collapse breakpoints critical for the page-shell refactor.
|
||||
// Snapshots use the -mobile-auth suffix to distinguish from the public mobile loop
|
||||
// (which also captures about-mobile unauthenticated, so names must not collide).
|
||||
const authenticatedMobilePages = [
|
||||
{ name: 'about', path: '/about' },
|
||||
{ name: 'member-dashboard', path: '/member/dashboard' },
|
||||
{ name: 'member-profile', path: '/member/profile' },
|
||||
{ name: 'member-account', path: '/member/account' },
|
||||
{ name: 'connections', path: '/connections' },
|
||||
]
|
||||
|
||||
// Path where the saved admin auth state (cookies) will be stored within a run.
|
||||
const authStatePath = path.resolve('e2e/.auth/admin.json')
|
||||
|
||||
// Wait for fonts and images to load before taking screenshots
|
||||
async function waitForStable(page) {
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
|
@ -27,47 +52,130 @@ async function waitForStable(page) {
|
|||
await page.evaluate(() => document.fonts.ready)
|
||||
}
|
||||
|
||||
test.describe('visual regression — public pages', () => {
|
||||
for (const { name, path } of publicPages) {
|
||||
for (const [viewportName, viewport] of Object.entries(viewports)) {
|
||||
test(`${name} — ${viewportName}`, async ({ page }) => {
|
||||
await page.setViewportSize(viewport)
|
||||
// Common mask selectors for dynamic content
|
||||
function commonMasks(page) {
|
||||
return [
|
||||
// Dates and times throughout the app
|
||||
page.locator('.event-date'),
|
||||
page.locator('.event-count'),
|
||||
page.locator('time'),
|
||||
page.locator('.member-since'),
|
||||
// Activity log timestamps
|
||||
page.locator('.tl-time'),
|
||||
// Admin dashboard stat values (member counts, revenue, etc.)
|
||||
page.locator('.stat-val'),
|
||||
// Recent member join dates in admin dashboard
|
||||
page.locator('.item-date'),
|
||||
// Member avatars (ghost images may not load deterministically)
|
||||
page.locator('.mc-avatar'),
|
||||
page.locator('.cc-avatar'),
|
||||
page.locator('.profile-avatar'),
|
||||
// Member count text in members page filter bar
|
||||
page.locator('.filter-count'),
|
||||
// Connections page: filter bar and suggestions vary based on tag/topic
|
||||
// state and async fetch ordering. Mask them to keep the structural
|
||||
// (PageShell + page-level) regression coverage stable.
|
||||
page.locator('.filter-bar'),
|
||||
page.locator('.skills-bar'),
|
||||
page.locator('.connections-section'),
|
||||
page.locator('.loading-state'),
|
||||
]
|
||||
}
|
||||
|
||||
// All visual tests run serially in a single top-level describe block.
|
||||
//
|
||||
// Auth is handled with a beforeAll that saves the cookie to disk once. All
|
||||
// authenticated sub-describes load from that saved state, avoiding repeated
|
||||
// /api/dev/test-login calls that exhaust the dev server's MongoDB connections.
|
||||
test.describe('visual regression', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
// Log in once before all tests and save the auth cookie.
|
||||
// serial mode guarantees this runs before any test in this describe tree.
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
fs.mkdirSync(path.dirname(authStatePath), { recursive: true })
|
||||
const page = await browser.newPage()
|
||||
await loginAsAdmin(page)
|
||||
await page.context().storageState({ path: authStatePath })
|
||||
await page.close()
|
||||
})
|
||||
|
||||
// ── Public pages (desktop + mobile) ──────────────────────────────────────
|
||||
test.describe('public pages', () => {
|
||||
for (const { name, path } of publicPages) {
|
||||
for (const [viewportName, viewport] of Object.entries(viewports)) {
|
||||
test(`${name} — ${viewportName}`, async ({ page }) => {
|
||||
await page.setViewportSize(viewport)
|
||||
await page.goto(path)
|
||||
await waitForStable(page)
|
||||
|
||||
await expect(page).toHaveScreenshot(`${name}-${viewportName}.png`, {
|
||||
maxDiffPixelRatio: 0.01,
|
||||
mask: commonMasks(page),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── Authenticated pages (desktop) ─────────────────────────────────────────
|
||||
// Loads saved auth cookie — no repeated /api/dev/test-login calls.
|
||||
test.describe('authenticated pages', () => {
|
||||
test.use({ storageState: authStatePath })
|
||||
|
||||
for (const { name, path } of authenticatedPages) {
|
||||
test(`${name} — desktop`, async ({ page }) => {
|
||||
await page.setViewportSize(viewports.desktop)
|
||||
await page.goto(path)
|
||||
await waitForStable(page)
|
||||
|
||||
await expect(page).toHaveScreenshot(`${name}-${viewportName}.png`, {
|
||||
await expect(page).toHaveScreenshot(`${name}-desktop.png`, {
|
||||
maxDiffPixelRatio: 0.01,
|
||||
mask: [
|
||||
// Mask dynamic content like dates and counts
|
||||
page.locator('.event-date'),
|
||||
page.locator('.event-count'),
|
||||
page.locator('time'),
|
||||
],
|
||||
mask: commonMasks(page),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('visual regression — authenticated pages', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsAdmin(page)
|
||||
})
|
||||
|
||||
for (const { name, path } of authenticatedPages) {
|
||||
test(`${name} — desktop`, async ({ page }) => {
|
||||
// members-detail: navigate to the test admin's own profile page.
|
||||
// The test admin is created by /api/dev/test-login (email: test-admin@ghostguild.dev,
|
||||
// status: active). We fetch their _id from /api/auth/member using the saved cookie.
|
||||
// Even if showInDirectory is false, the page renders a stable error or profile shell.
|
||||
test('members-detail — desktop', async ({ page }) => {
|
||||
await page.setViewportSize(viewports.desktop)
|
||||
await page.goto(path)
|
||||
const response = await page.request.get('/api/auth/member')
|
||||
// /api/auth/member returns the member object directly (not nested under a 'member' key)
|
||||
const authData = response.ok() ? await response.json() : null
|
||||
const memberId = authData?._id || authData?.id
|
||||
if (!memberId) {
|
||||
// Skip gracefully if we can't retrieve the member ID
|
||||
test.skip(true, 'Could not retrieve test admin member ID from /api/auth/member')
|
||||
return
|
||||
}
|
||||
await page.goto(`/members/${memberId}`)
|
||||
await waitForStable(page)
|
||||
|
||||
await expect(page).toHaveScreenshot(`${name}-desktop.png`, {
|
||||
await expect(page).toHaveScreenshot('members-detail-desktop.png', {
|
||||
maxDiffPixelRatio: 0.01,
|
||||
mask: [
|
||||
page.locator('.event-date'),
|
||||
page.locator('time'),
|
||||
page.locator('.member-since'),
|
||||
],
|
||||
mask: commonMasks(page),
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ── Authenticated pages (mobile — column-collapse coverage) ───────────────
|
||||
// Loads saved auth cookie — no repeated /api/dev/test-login calls.
|
||||
test.describe('authenticated pages (mobile)', () => {
|
||||
test.use({ storageState: authStatePath })
|
||||
|
||||
for (const { name, path } of authenticatedMobilePages) {
|
||||
test(`${name} — mobile`, async ({ page }) => {
|
||||
await page.setViewportSize(viewports.mobile)
|
||||
await page.goto(path)
|
||||
await waitForStable(page)
|
||||
|
||||
await expect(page).toHaveScreenshot(`${name}-mobile-auth.png`, {
|
||||
maxDiffPixelRatio: 0.01,
|
||||
mask: commonMasks(page),
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
const PORT = process.env.PLAYWRIGHT_PORT || "3000";
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
outputDir: "e2e/test-results",
|
||||
|
|
@ -11,7 +14,7 @@ export default defineConfig({
|
|||
reporter: "html",
|
||||
timeout: 60000,
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
baseURL: BASE_URL,
|
||||
trace: "on-first-retry",
|
||||
navigationTimeout: 45000,
|
||||
},
|
||||
|
|
@ -22,8 +25,8 @@ export default defineConfig({
|
|||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "npm run build && NODE_ENV=development npm run preview",
|
||||
url: "http://localhost:3000",
|
||||
command: `PORT=${PORT} npm run build && PORT=${PORT} NODE_ENV=development npm run preview`,
|
||||
url: BASE_URL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
env: {
|
||||
NUXT_PUBLIC_COMING_SOON: "false",
|
||||
|
|
|
|||