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
|
# Playwright
|
||||||
e2e/test-results/
|
e2e/test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
e2e/.auth/
|
||||||
|
|
||||||
# Worktrees
|
# Worktrees
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@
|
||||||
--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);
|
--ember-bg: rgba(138, 68, 32, 0.1);
|
||||||
|
--page-pad-x: 28px;
|
||||||
|
--page-pad-y: 24px;
|
||||||
|
--page-collapse: 1024px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
|
|
@ -65,6 +68,9 @@
|
||||||
--green: #6e9c52;
|
--green: #6e9c52;
|
||||||
--green-bg: rgba(110, 156, 82, 0.12);
|
--green-bg: rgba(110, 156, 82, 0.12);
|
||||||
--ember-bg: rgba(202, 106, 58, 0.14);
|
--ember-bg: rgba(202, 106, 58, 0.14);
|
||||||
|
--page-pad-x: 28px;
|
||||||
|
--page-pad-y: 24px;
|
||||||
|
--page-collapse: 1024px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- TAILWIND @THEME MAPPING ---- */
|
/* ---- 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>
|
<style scoped>
|
||||||
.page-header {
|
.page-header {
|
||||||
padding: 24px 28px 16px;
|
padding: var(--page-pad-y) var(--page-pad-x) 16px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
.page-header h1 {
|
.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>
|
<template>
|
||||||
<div class="about-page">
|
<PageShell>
|
||||||
<!-- ABOUT HERO (side by side) -->
|
<!-- ABOUT HERO (side by side) -->
|
||||||
<div class="about-hero">
|
<div class="about-hero">
|
||||||
<div class="about-hero-left">
|
<div class="about-hero-left">
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
|
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
|
||||||
<SidebarLayout>
|
<ColumnsLayout cols="events-sidebar" :limit="3">
|
||||||
<!-- 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>
|
||||||
|
|
@ -111,21 +111,13 @@
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</SidebarLayout>
|
</ColumnsLayout>
|
||||||
</div>
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup></script>
|
<script setup></script>
|
||||||
|
|
||||||
<style scoped>
|
<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 ---- */
|
||||||
.about-hero {
|
.about-hero {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="admin-dash">
|
<PageShell
|
||||||
<!-- Page Header -->
|
title="Admin Dashboard"
|
||||||
<div class="page-header">
|
subtitle="Members, events, and community operations"
|
||||||
<h1>Admin Dashboard</h1>
|
>
|
||||||
<p>Members, events, and community operations</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AdminAlertsPanel />
|
<AdminAlertsPanel />
|
||||||
|
|
||||||
<!-- Stats + Quick Actions row -->
|
<!-- Stats + Quick Actions row -->
|
||||||
<div class="content-row">
|
<ColumnsLayout cols="2" collapse="768" class="admin-row">
|
||||||
<div class="content-block">
|
<template #left>
|
||||||
|
<div class="admin-block">
|
||||||
<div class="section-label">Overview</div>
|
<div class="section-label">Overview</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-key">Total Members</span>
|
<span class="stat-key">Total Members</span>
|
||||||
|
|
@ -29,8 +27,10 @@
|
||||||
<span class="stat-val">{{ stats.pendingSlackInvites || 0 }}</span>
|
<span class="stat-val">{{ stats.pendingSlackInvites || 0 }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="content-block">
|
<template #right>
|
||||||
|
<div class="admin-block">
|
||||||
<div class="section-label">Quick Actions</div>
|
<div class="section-label">Quick Actions</div>
|
||||||
<NuxtLink to="/admin/members" class="action-link">
|
<NuxtLink to="/admin/members" class="action-link">
|
||||||
Manage Members<span class="arrow">→</span>
|
Manage Members<span class="arrow">→</span>
|
||||||
|
|
@ -45,11 +45,13 @@
|
||||||
Create Series<span class="arrow">→</span>
|
Create Series<span class="arrow">→</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</ColumnsLayout>
|
||||||
|
|
||||||
<!-- Recent Activity row -->
|
<!-- Recent Activity row -->
|
||||||
<div class="content-row">
|
<ColumnsLayout cols="2" collapse="768" class="admin-row">
|
||||||
<div class="content-block">
|
<template #left>
|
||||||
|
<div class="admin-block">
|
||||||
<div class="section-label">Recent Members</div>
|
<div class="section-label">Recent Members</div>
|
||||||
|
|
||||||
<div v-if="pending" class="loading-inline">
|
<div v-if="pending" class="loading-inline">
|
||||||
|
|
@ -72,8 +74,10 @@
|
||||||
|
|
||||||
<NuxtLink to="/admin/members" class="section-link">View all members →</NuxtLink>
|
<NuxtLink to="/admin/members" class="section-link">View all members →</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="content-block">
|
<template #right>
|
||||||
|
<div class="admin-block">
|
||||||
<div class="section-label">Upcoming Events</div>
|
<div class="section-label">Upcoming Events</div>
|
||||||
|
|
||||||
<div v-if="pending" class="loading-inline">
|
<div v-if="pending" class="loading-inline">
|
||||||
|
|
@ -96,8 +100,9 @@
|
||||||
|
|
||||||
<NuxtLink to="/admin/events" class="section-link">View all events →</NuxtLink>
|
<NuxtLink to="/admin/events" class="section-link">View all events →</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</ColumnsLayout>
|
||||||
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|
@ -127,45 +132,16 @@ const formatDateTime = (dateString) => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.admin-dash {}
|
/* ---- ROWS ---- */
|
||||||
|
.admin-row {
|
||||||
/* ---- PAGE HEADER ---- */
|
|
||||||
.page-header {
|
|
||||||
padding: 28px 28px 20px;
|
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header h1 {
|
.admin-block {
|
||||||
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 {
|
|
||||||
padding: 24px 28px;
|
padding: 24px 28px;
|
||||||
border-right: 1px dashed var(--border);
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-block:last-child {
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- STATS ---- */
|
/* ---- STATS ---- */
|
||||||
.stat-row {
|
.stat-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -313,24 +289,7 @@ a.item-name:hover {
|
||||||
|
|
||||||
/* ---- RESPONSIVE ---- */
|
/* ---- RESPONSIVE ---- */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.content-row {
|
.admin-block {
|
||||||
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 {
|
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="connections-page">
|
<PageShell
|
||||||
<PageHeader
|
|
||||||
title="Connections"
|
title="Connections"
|
||||||
subtitle="Find members who share your cooperative interests"
|
subtitle="Find members who share your cooperative interests"
|
||||||
/>
|
>
|
||||||
|
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="loading" class="loading-state">
|
<div v-if="loading" class="loading-state">
|
||||||
|
|
@ -312,7 +310,7 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="member-account-page">
|
<PageShell>
|
||||||
|
<ClientOnly>
|
||||||
<!-- 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>
|
||||||
|
|
@ -11,7 +12,7 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="account-authenticated">
|
<template v-else>
|
||||||
<!-- PAGE HEADER -->
|
<!-- PAGE HEADER -->
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Account Settings"
|
title="Account Settings"
|
||||||
|
|
@ -19,12 +20,11 @@
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
|
<!-- CONTENT AREA WITH EVENTS SIDEBAR -->
|
||||||
<SidebarLayout>
|
<ColumnsLayout cols="events-sidebar">
|
||||||
<div class="account-columns">
|
<ColumnsLayout cols="2">
|
||||||
<!-- LEFT COLUMN: Membership Status & Email -->
|
<!-- LEFT COLUMN: Membership Status & Email -->
|
||||||
<div class="account-col-left">
|
<template #left>
|
||||||
<section class="account-section">
|
<PageSection>
|
||||||
<div class="account-col-inset">
|
|
||||||
<div class="section-label">Current Membership</div>
|
<div class="section-label">Current Membership</div>
|
||||||
|
|
||||||
<div class="membership-card">
|
<div class="membership-card">
|
||||||
|
|
@ -68,11 +68,9 @@
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageSection>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="account-section">
|
<PageSection divider="top">
|
||||||
<div class="account-col-inset">
|
|
||||||
<div class="section-label">Email</div>
|
<div class="section-label">Email</div>
|
||||||
|
|
||||||
<div v-if="!showEmailEdit" class="email-display">
|
<div v-if="!showEmailEdit" class="email-display">
|
||||||
|
|
@ -115,11 +113,9 @@
|
||||||
<div class="email-hint">
|
<div class="email-hint">
|
||||||
Used for login magic links and notifications
|
Used for login magic links and notifications
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageSection>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="account-section account-section--danger">
|
<PageSection divider="top" class="danger-section">
|
||||||
<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>
|
<p>
|
||||||
|
|
@ -153,14 +149,12 @@
|
||||||
Cancel Membership
|
Cancel Membership
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageSection>
|
||||||
</section>
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- RIGHT COLUMN: Change Contribution & Circle -->
|
<!-- RIGHT COLUMN: Change Contribution & Circle -->
|
||||||
<div class="account-col-right">
|
<template #right>
|
||||||
<section class="account-section">
|
<PageSection>
|
||||||
<div class="account-col-inset">
|
|
||||||
<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" />
|
||||||
|
|
@ -177,11 +171,9 @@
|
||||||
>
|
>
|
||||||
{{ isUpdating ? "Updating…" : "Update Contribution" }}
|
{{ isUpdating ? "Updating…" : "Update Contribution" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</PageSection>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="account-section">
|
<PageSection divider="top">
|
||||||
<div class="account-col-inset">
|
|
||||||
<div class="section-label">Change Circle</div>
|
<div class="section-label">Change Circle</div>
|
||||||
|
|
||||||
<CirclePicker
|
<CirclePicker
|
||||||
|
|
@ -195,13 +187,13 @@
|
||||||
>
|
>
|
||||||
{{ isUpdating ? "Updating…" : "Update Circle" }}
|
{{ isUpdating ? "Updating…" : "Update Circle" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</PageSection>
|
||||||
</section>
|
</template>
|
||||||
</div>
|
</ColumnsLayout>
|
||||||
</div>
|
</ColumnsLayout>
|
||||||
</SidebarLayout>
|
</template>
|
||||||
</div>
|
</ClientOnly>
|
||||||
</div>
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|
@ -385,82 +377,11 @@ const confirmCancelMembership = async () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.member-account-page {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
flex: 1;
|
|
||||||
padding: 48px 32px;
|
padding: 48px 32px;
|
||||||
color: var(--text-dim);
|
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 ---- */
|
||||||
.membership-card {
|
.membership-card {
|
||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
|
|
@ -567,14 +488,14 @@ const confirmCancelMembership = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- DANGER ZONE ---- */
|
/* ---- DANGER ZONE ---- */
|
||||||
.account-section--danger {
|
.danger-section {
|
||||||
background: var(--ember-bg);
|
background: var(--ember-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-section--danger .section-label.danger {
|
.danger-section .section-label.danger {
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
}
|
}
|
||||||
.account-section--danger .danger-zone p {
|
.danger-section .danger-zone p {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
|
|
@ -610,19 +531,4 @@ const confirmCancelMembership = async () => {
|
||||||
text-align: center;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="activity-page">
|
<PageShell
|
||||||
<PageHeader
|
|
||||||
title="Activity Log"
|
title="Activity Log"
|
||||||
subtitle="Your activity and milestones in the Guild"
|
subtitle="Your activity and milestones in the Guild"
|
||||||
/>
|
>
|
||||||
|
<ColumnsLayout cols="events-sidebar" :limit="5">
|
||||||
<SidebarLayout :limit="5">
|
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="loading && !entries.length" class="state-box">
|
<div v-if="loading && !entries.length" class="state-box">
|
||||||
|
|
@ -66,8 +64,8 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</SidebarLayout>
|
</ColumnsLayout>
|
||||||
</div>
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|
@ -142,13 +140,6 @@ useHead({ title: 'Activity Log - Ghost Guild' })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.activity-page {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- STATE BOXES ---- */
|
/* ---- STATE BOXES ---- */
|
||||||
.state-box {
|
.state-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="dashboard">
|
<PageShell>
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="authPending" class="loading-state">
|
<div v-if="authPending" class="loading-state">
|
||||||
|
|
@ -26,19 +26,17 @@
|
||||||
|
|
||||||
<!-- Dashboard Content -->
|
<!-- Dashboard Content -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<SidebarLayout :limit="5">
|
<ColumnsLayout cols="events-sidebar" :limit="5">
|
||||||
<div class="dashboard-body">
|
|
||||||
<!-- Member Status Banner -->
|
<!-- Member Status Banner -->
|
||||||
<MemberStatusBanner />
|
<MemberStatusBanner />
|
||||||
|
|
||||||
<!-- Welcome Header -->
|
<!-- Welcome Header -->
|
||||||
<div class="welcome">
|
<PageHeader :title="`Welcome back, ${memberData?.name || ''}`">
|
||||||
<h1>Welcome back, {{ memberData?.name }}</h1>
|
<div class="dashboard-meta">
|
||||||
<div class="meta">
|
|
||||||
<CircleBadge :circle="memberData?.circle || 'community'" />
|
<CircleBadge :circle="memberData?.circle || 'community'" />
|
||||||
<span>${{ memberData?.contributionTier }} CAD/mo</span>
|
<span>${{ memberData?.contributionTier }} CAD/mo</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Upcoming Events + Quick Actions -->
|
<!-- Upcoming Events + Quick Actions -->
|
||||||
<div class="content-row">
|
<div class="content-row">
|
||||||
|
|
@ -203,8 +201,7 @@
|
||||||
</DashedBox>
|
</DashedBox>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ColumnsLayout>
|
||||||
</SidebarLayout>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
|
|
@ -214,7 +211,7 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|
@ -376,15 +373,6 @@ useHead({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* ---- DASHBOARD LAYOUT ---- */
|
|
||||||
.dashboard {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- LOADING / UNAUTH STATES ---- */
|
/* ---- LOADING / UNAUTH STATES ---- */
|
||||||
.loading-state {
|
.loading-state {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
@ -451,37 +439,14 @@ useHead({
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- WELCOME HEADER ---- */
|
/* ---- WELCOME HEADER META ---- */
|
||||||
.welcome {
|
.dashboard-meta {
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
margin-top: 8px;
|
||||||
|
|
||||||
.dashboard-body {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-row {
|
.content-row {
|
||||||
|
|
@ -722,10 +687,6 @@ useHead({
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome {
|
|
||||||
padding: 24px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-block {
|
.content-block {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="profile-page">
|
<PageShell as="form" @submit.prevent="handleSubmit">
|
||||||
|
<ClientOnly>
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="loading" class="loading-state">
|
<div v-if="loading" class="loading-state">
|
||||||
<p style="color: var(--text-faint)">Loading your profile...</p>
|
<p style="color: var(--text-faint)">Loading your profile...</p>
|
||||||
|
|
@ -11,6 +12,7 @@
|
||||||
Please sign in to access your profile settings.
|
Please sign in to access your profile settings.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@click="
|
@click="
|
||||||
openLoginModal({
|
openLoginModal({
|
||||||
|
|
@ -23,7 +25,7 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="profile-authenticated">
|
<template v-else>
|
||||||
<!-- PAGE HEADER -->
|
<!-- PAGE HEADER -->
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Edit Profile"
|
title="Edit Profile"
|
||||||
|
|
@ -42,13 +44,11 @@
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<!-- TWO-COLUMN FORM -->
|
<!-- TWO-COLUMN FORM BODY -->
|
||||||
<form class="page-content" @submit.prevent="handleSubmit">
|
<ColumnsLayout cols="2">
|
||||||
<div class="profile-main">
|
|
||||||
<div class="profile-columns">
|
|
||||||
<!-- ======== LEFT COLUMN ======== -->
|
<!-- ======== LEFT COLUMN ======== -->
|
||||||
<div class="profile-col-left">
|
<template #left>
|
||||||
<div class="profile-col-inset">
|
<PageSection>
|
||||||
<div class="section-label">Basics</div>
|
<div class="section-label">Basics</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
@ -99,11 +99,10 @@
|
||||||
</div>
|
</div>
|
||||||
<PrivacyToggle v-model="formData.avatarPrivacy" />
|
<PrivacyToggle v-model="formData.avatarPrivacy" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageSection>
|
||||||
|
|
||||||
<!-- About You -->
|
<!-- About You -->
|
||||||
<hr class="section-divider" />
|
<PageSection divider="top">
|
||||||
<div class="profile-col-inset">
|
|
||||||
<div class="section-label">About You</div>
|
<div class="section-label">About You</div>
|
||||||
|
|
||||||
<div class="row-2">
|
<div class="row-2">
|
||||||
|
|
@ -151,11 +150,10 @@
|
||||||
/>
|
/>
|
||||||
<PrivacyToggle v-model="formData.craftTagsPrivacy" />
|
<PrivacyToggle v-model="formData.craftTagsPrivacy" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageSection>
|
||||||
|
|
||||||
<!-- Visibility -->
|
<!-- Visibility -->
|
||||||
<hr class="section-divider" />
|
<PageSection divider="top">
|
||||||
<div class="profile-col-inset">
|
|
||||||
<div class="section-label">Visibility</div>
|
<div class="section-label">Visibility</div>
|
||||||
|
|
||||||
<div class="toggle-field">
|
<div class="toggle-field">
|
||||||
|
|
@ -171,12 +169,12 @@
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageSection>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- ======== RIGHT COLUMN ======== -->
|
<!-- ======== RIGHT COLUMN ======== -->
|
||||||
<div class="profile-col-right">
|
<template #right>
|
||||||
<div class="profile-col-inset">
|
<PageSection>
|
||||||
<div class="section-label">Community Connections</div>
|
<div class="section-label">Community Connections</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
@ -247,11 +245,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageSection>
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<hr class="section-divider" />
|
<PageSection divider="top">
|
||||||
<div class="profile-col-inset">
|
|
||||||
<div class="section-label">Notifications</div>
|
<div class="section-label">Notifications</div>
|
||||||
|
|
||||||
<div class="toggle-field">
|
<div class="toggle-field">
|
||||||
|
|
@ -292,9 +289,9 @@
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageSection>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</ColumnsLayout>
|
||||||
|
|
||||||
<!-- ======== SAVE BAR ======== -->
|
<!-- ======== SAVE BAR ======== -->
|
||||||
<div class="save-bar">
|
<div class="save-bar">
|
||||||
|
|
@ -315,13 +312,12 @@
|
||||||
saveError
|
saveError
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</form>
|
</ClientOnly>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tag Suggest Modal -->
|
<!-- Tag Suggest Modal -->
|
||||||
<TagSuggestModal v-model:open="showTagSuggestModal" :pool="tagSuggestPool" />
|
<TagSuggestModal v-model:open="showTagSuggestModal" :pool="tagSuggestPool" />
|
||||||
</div>
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|
@ -546,20 +542,6 @@ useHead({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 / EMPTY STATE ---- */
|
||||||
.loading-state {
|
.loading-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -570,66 +552,6 @@ useHead({
|
||||||
text-align: center;
|
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 ---- */
|
/* ---- MULTI-COLUMN ROWS ---- */
|
||||||
.row-2 {
|
.row-2 {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -783,37 +705,11 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- RESPONSIVE ---- */
|
/* ---- 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) {
|
@media (max-width: 768px) {
|
||||||
.row-2 {
|
.row-2 {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-col-left .profile-col-inset,
|
|
||||||
.profile-col-right .profile-col-inset {
|
|
||||||
padding-left: 16px;
|
|
||||||
padding-right: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-bar {
|
.save-bar {
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
padding-right: 16px;
|
padding-right: 16px;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="profile-page">
|
<PageShell>
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="pending" class="loading-state">
|
<div v-if="pending" class="loading-state">
|
||||||
<p>Loading profile...</p>
|
<p>Loading profile...</p>
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
<!-- Profile Content -->
|
<!-- Profile Content -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|
||||||
<!-- HERO: full-bleed, outside SidebarLayout -->
|
<!-- HERO: full-bleed, outside ColumnsLayout -->
|
||||||
<div class="profile-hero" :class="{ 'profile-hero--with-links': hasSocialLinks }">
|
<div class="profile-hero" :class="{ 'profile-hero--with-links': hasSocialLinks }">
|
||||||
|
|
||||||
<!-- Left: Avatar + Identity -->
|
<!-- Left: Avatar + Identity -->
|
||||||
|
|
@ -99,8 +99,8 @@
|
||||||
</div>
|
</div>
|
||||||
<!-- END HERO -->
|
<!-- END HERO -->
|
||||||
|
|
||||||
<!-- SidebarLayout wraps all remaining sections -->
|
<!-- ColumnsLayout wraps all remaining sections -->
|
||||||
<SidebarLayout>
|
<ColumnsLayout cols="events-sidebar">
|
||||||
|
|
||||||
<!-- Bio: parch (inverted) block -->
|
<!-- Bio: parch (inverted) block -->
|
||||||
<div v-if="member.bio" class="profile-section profile-section--parch">
|
<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>
|
<NuxtLink to="/members" class="back-link">← Back to Members</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</SidebarLayout>
|
</ColumnsLayout>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|
@ -361,19 +361,6 @@ useHead({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 ---- */
|
||||||
.loading-state {
|
.loading-state {
|
||||||
padding: 80px 32px;
|
padding: 80px 32px;
|
||||||
|
|
@ -527,7 +514,7 @@ useHead({
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================================================
|
/* ====================================================
|
||||||
SECTIONS — inside SidebarLayout
|
SECTIONS — inside ColumnsLayout
|
||||||
==================================================== */
|
==================================================== */
|
||||||
|
|
||||||
.profile-section {
|
.profile-section {
|
||||||
|
|
@ -747,7 +734,7 @@ useHead({
|
||||||
==================================================== */
|
==================================================== */
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
/* SidebarLayout sidebar hides itself at ≤1024px */
|
/* ColumnsLayout events-sidebar hides itself at ≤1024px */
|
||||||
.profile-two-col {
|
.profile-two-col {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="members-page">
|
<PageShell
|
||||||
<!-- Page Header -->
|
|
||||||
<PageHeader
|
|
||||||
title="Members"
|
title="Members"
|
||||||
:subtitle="`${totalCount} member${totalCount === 1 ? '' : 's'} across 3 circles`"
|
:subtitle="`${totalCount} member${totalCount === 1 ? '' : 's'} across 3 circles`"
|
||||||
/>
|
>
|
||||||
|
|
||||||
<!-- Filter Bar -->
|
<!-- Filter Bar -->
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<input
|
<input
|
||||||
|
|
@ -264,7 +261,7 @@
|
||||||
<NuxtLink to="/join" class="btn btn-primary">Join Ghost Guild</NuxtLink>
|
<NuxtLink to="/join" class="btn btn-primary">Join Ghost Guild</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Event Series"
|
title="Event Series"
|
||||||
subtitle="Discover our multi-event series designed to take you on a journey of learning and growth"
|
subtitle="Discover our multi-event series designed to take you on a journey of learning and growth"
|
||||||
size="large"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Series Grid -->
|
<!-- Series Grid -->
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Welcome to Ghost Guild"
|
title="Welcome to Ghost Guild"
|
||||||
subtitle="You're officially part of the community!"
|
subtitle="You're officially part of the community!"
|
||||||
size="large"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section class="py-16 bg-guild-900">
|
<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 { test, expect } from '@playwright/test'
|
||||||
import { loginAsAdmin } from '../helpers/auth.js'
|
import { loginAsAdmin } from '../helpers/auth.js'
|
||||||
|
import path from 'path'
|
||||||
|
import fs from 'fs'
|
||||||
|
|
||||||
const viewports = {
|
const viewports = {
|
||||||
desktop: { width: 1280, height: 720 },
|
desktop: { width: 1280, height: 720 },
|
||||||
|
|
@ -11,6 +13,9 @@ const publicPages = [
|
||||||
{ name: 'join', path: '/join' },
|
{ name: 'join', path: '/join' },
|
||||||
{ name: 'events', path: '/events' },
|
{ name: 'events', path: '/events' },
|
||||||
{ name: 'coming-soon', path: '/coming-soon' },
|
{ 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 = [
|
const authenticatedPages = [
|
||||||
|
|
@ -18,8 +23,28 @@ const authenticatedPages = [
|
||||||
{ name: 'member-profile', path: '/member/profile' },
|
{ name: 'member-profile', path: '/member/profile' },
|
||||||
{ name: 'admin-members', path: '/admin/members' },
|
{ name: 'admin-members', path: '/admin/members' },
|
||||||
{ name: 'admin-events-create', path: '/admin/events/create' },
|
{ 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
|
// Wait for fonts and images to load before taking screenshots
|
||||||
async function waitForStable(page) {
|
async function waitForStable(page) {
|
||||||
await page.waitForLoadState('networkidle')
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
@ -27,7 +52,56 @@ async function waitForStable(page) {
|
||||||
await page.evaluate(() => document.fonts.ready)
|
await page.evaluate(() => document.fonts.ready)
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('visual regression — public pages', () => {
|
// 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 { name, path } of publicPages) {
|
||||||
for (const [viewportName, viewport] of Object.entries(viewports)) {
|
for (const [viewportName, viewport] of Object.entries(viewports)) {
|
||||||
test(`${name} — ${viewportName}`, async ({ page }) => {
|
test(`${name} — ${viewportName}`, async ({ page }) => {
|
||||||
|
|
@ -37,22 +111,17 @@ test.describe('visual regression — public pages', () => {
|
||||||
|
|
||||||
await expect(page).toHaveScreenshot(`${name}-${viewportName}.png`, {
|
await expect(page).toHaveScreenshot(`${name}-${viewportName}.png`, {
|
||||||
maxDiffPixelRatio: 0.01,
|
maxDiffPixelRatio: 0.01,
|
||||||
mask: [
|
mask: commonMasks(page),
|
||||||
// Mask dynamic content like dates and counts
|
|
||||||
page.locator('.event-date'),
|
|
||||||
page.locator('.event-count'),
|
|
||||||
page.locator('time'),
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test.describe('visual regression — authenticated pages', () => {
|
// ── Authenticated pages (desktop) ─────────────────────────────────────────
|
||||||
test.beforeEach(async ({ page }) => {
|
// Loads saved auth cookie — no repeated /api/dev/test-login calls.
|
||||||
await loginAsAdmin(page)
|
test.describe('authenticated pages', () => {
|
||||||
})
|
test.use({ storageState: authStatePath })
|
||||||
|
|
||||||
for (const { name, path } of authenticatedPages) {
|
for (const { name, path } of authenticatedPages) {
|
||||||
test(`${name} — desktop`, async ({ page }) => {
|
test(`${name} — desktop`, async ({ page }) => {
|
||||||
|
|
@ -62,12 +131,51 @@ test.describe('visual regression — authenticated pages', () => {
|
||||||
|
|
||||||
await expect(page).toHaveScreenshot(`${name}-desktop.png`, {
|
await expect(page).toHaveScreenshot(`${name}-desktop.png`, {
|
||||||
maxDiffPixelRatio: 0.01,
|
maxDiffPixelRatio: 0.01,
|
||||||
mask: [
|
mask: commonMasks(page),
|
||||||
page.locator('.event-date'),
|
})
|
||||||
page.locator('time'),
|
})
|
||||||
page.locator('.member-since'),
|
}
|
||||||
],
|
|
||||||
|
// 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)
|
||||||
|
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('members-detail-desktop.png', {
|
||||||
|
maxDiffPixelRatio: 0.01,
|
||||||
|
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";
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
const PORT = process.env.PLAYWRIGHT_PORT || "3000";
|
||||||
|
const BASE_URL = `http://localhost:${PORT}`;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
outputDir: "e2e/test-results",
|
outputDir: "e2e/test-results",
|
||||||
|
|
@ -11,7 +14,7 @@ export default defineConfig({
|
||||||
reporter: "html",
|
reporter: "html",
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
use: {
|
use: {
|
||||||
baseURL: "http://localhost:3000",
|
baseURL: BASE_URL,
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
navigationTimeout: 45000,
|
navigationTimeout: 45000,
|
||||||
},
|
},
|
||||||
|
|
@ -22,8 +25,8 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "npm run build && NODE_ENV=development npm run preview",
|
command: `PORT=${PORT} npm run build && PORT=${PORT} NODE_ENV=development npm run preview`,
|
||||||
url: "http://localhost:3000",
|
url: BASE_URL,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
env: {
|
env: {
|
||||||
NUXT_PUBLIC_COMING_SOON: "false",
|
NUXT_PUBLIC_COMING_SOON: "false",
|
||||||
|
|
|
||||||