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.
This commit is contained in:
Jennie Robinson Faber 2026-04-08 21:05:59 +01:00
commit f9be1f3f01
39 changed files with 646 additions and 744 deletions

1
.gitignore vendored
View file

@ -29,6 +29,7 @@ scripts/*.js
# Playwright
e2e/test-results/
playwright-report/
e2e/.auth/
# Worktrees
.worktrees/

View file

@ -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 ---- */

View 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>

View file

@ -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 {

View 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>

View 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>

View file

@ -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:

View file

@ -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;

View file

@ -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">&rarr;</span>
</NuxtLink>
<NuxtLink to="/admin/events" class="action-link">
Manage Events<span class="arrow">&rarr;</span>
</NuxtLink>
<NuxtLink to="/admin/events/create" class="action-link">
Create Event<span class="arrow">&rarr;</span>
</NuxtLink>
<NuxtLink to="/admin/series/create" class="action-link">
Create Series<span class="arrow">&rarr;</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">&rarr;</span>
</NuxtLink>
<NuxtLink to="/admin/events" class="action-link">
Manage Events<span class="arrow">&rarr;</span>
</NuxtLink>
<NuxtLink to="/admin/events/create" class="action-link">
Create Event<span class="arrow">&rarr;</span>
</NuxtLink>
<NuxtLink to="/admin/series/create" class="action-link">
Create Series<span class="arrow">&rarr;</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 &rarr;</NuxtLink>
</div>
<div v-else class="empty-state">No recent members</div>
</template>
<NuxtLink to="/admin/members" class="section-link">View all members &rarr;</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 &rarr;</NuxtLink>
</div>
</div>
</div>
<NuxtLink to="/admin/events" class="section-link">View all events &rarr;</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;
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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;

View file

@ -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;
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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>

View file

@ -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 -->

View file

@ -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">

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 KiB

After

Width:  |  Height:  |  Size: 343 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View file

@ -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),
})
})
}
})
})

View file

@ -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",