|
diff --git a/app/pages/events/index.vue b/app/pages/events/index.vue
index f366ce6..1d05476 100644
--- a/app/pages/events/index.vue
+++ b/app/pages/events/index.vue
@@ -403,13 +403,12 @@ const isAlmostFull = (event) => {
color: var(--candle);
}
.cta-soon {
- color: var(--text-faint);
+ color: var(--text-dim);
cursor: default;
}
.cta-soon em {
font-style: normal;
font-size: 10px;
- opacity: 0.65;
}
.filter-toggle {
diff --git a/app/pages/member/dashboard.vue b/app/pages/member/dashboard.vue
index bf2de63..4173fd0 100644
--- a/app/pages/member/dashboard.vue
+++ b/app/pages/member/dashboard.vue
@@ -256,22 +256,21 @@ const copyCalendarLink = async () => {
const { openLoginModal } = useLoginModal();
// Handle authentication check on page load
+// server: false ensures this always runs on the client, even on a hard page load.
+// The auth middleware only fires for client-side navigations in Nuxt 4, so we
+// can't rely on it to open the modal when the user lands directly on this URL.
const { pending: authPending } = await useLazyAsyncData(
"dashboard-auth",
async () => {
- // Only check authentication on client side
- if (process.server) return null;
-
- // If no member data, try to authenticate
if (!memberData.value) {
const isAuthenticated = await checkMemberStatus();
if (!isAuthenticated) {
- // Show login modal instead of redirecting
openLoginModal({
- title: "Sign in to your dashboard",
- description: "Enter your email to access your member dashboard",
+ title: "Sign in to continue",
+ description: "You need to be signed in to access this page",
dismissible: true,
+ redirectTo: "/member/dashboard",
});
return null;
}
@@ -279,6 +278,7 @@ const { pending: authPending } = await useLazyAsyncData(
return memberData.value;
},
+ { server: false },
);
// Load registered events
diff --git a/app/pages/member/profile.vue b/app/pages/member/profile.vue
index e141981..2c40b27 100644
--- a/app/pages/member/profile.vue
+++ b/app/pages/member/profile.vue
@@ -188,7 +188,10 @@
Visibility
-
+
Show in Member Directory
Peer Support
-
+
Offer Peer Support
Notifications
-
+
Event reminders
-
+
Community updates
-
+
Peer support requests
{
+test.describe("accessibility โ public pages", () => {
for (const { name, path } of publicPages) {
test(`${name} has no critical a11y violations`, async ({ page }) => {
- await page.goto(path)
- await page.waitForLoadState('networkidle')
+ await page.goto(path);
+ await page.waitForLoadState("networkidle");
const results = await new AxeBuilder({ page })
- .withTags(['wcag2a', 'wcag2aa'])
- .analyze()
+ .withTags(["wcag2a", "wcag2aa"])
+ .analyze();
const critical = results.violations.filter(
- (v) => v.impact === 'critical' || v.impact === 'serious'
- )
+ (v) => v.impact === "critical" || v.impact === "serious",
+ );
- expect(critical, `${name} has critical/serious a11y issues`).toEqual([])
- })
+ expect(critical, `${name} has critical/serious a11y issues`).toEqual([]);
+ });
}
-})
+});
-test.describe('accessibility โ member pages', () => {
+test.describe("accessibility โ member pages", () => {
test.beforeEach(async ({ page }) => {
- await loginAsAdmin(page)
- })
+ await loginAsAdmin(page);
+ });
for (const { name, path } of memberPages) {
test(`${name} has no critical a11y violations`, async ({ page }) => {
- await page.goto(path)
- await page.waitForLoadState('networkidle')
+ await page.goto(path);
+ await page.waitForLoadState("networkidle");
const results = await new AxeBuilder({ page })
- .withTags(['wcag2a', 'wcag2aa'])
- .analyze()
+ .withTags(["wcag2a", "wcag2aa"])
+ .analyze();
const critical = results.violations.filter(
- (v) => v.impact === 'critical' || v.impact === 'serious'
- )
+ (v) => v.impact === "critical" || v.impact === "serious",
+ );
- expect(critical, `${name} has critical/serious a11y issues`).toEqual([])
- })
+ expect(critical, `${name} has critical/serious a11y issues`).toEqual([]);
+ });
}
-})
+});
-test.describe('accessibility โ admin pages', () => {
+test.describe("accessibility โ admin pages", () => {
test.beforeEach(async ({ page }) => {
- await loginAsAdmin(page)
- })
+ await loginAsAdmin(page);
+ });
for (const { name, path } of adminPages) {
test(`${name} has no critical a11y violations`, async ({ page }) => {
- await page.goto(path)
- await page.waitForLoadState('networkidle')
+ await page.goto(path);
+ await page.waitForLoadState("networkidle");
const results = await new AxeBuilder({ page })
- .withTags(['wcag2a', 'wcag2aa'])
- .analyze()
+ .withTags(["wcag2a", "wcag2aa"])
+ .analyze();
const critical = results.violations.filter(
- (v) => v.impact === 'critical' || v.impact === 'serious'
- )
+ (v) => v.impact === "critical" || v.impact === "serious",
+ );
- expect(critical, `${name} has critical/serious a11y issues`).toEqual([])
- })
+ expect(critical, `${name} has critical/serious a11y issues`).toEqual([]);
+ });
}
-})
+});
-test.describe('keyboard navigation', () => {
- test('tab through join form fields in order', async ({ page }) => {
- await page.goto('/join')
- await page.waitForLoadState('networkidle')
+test.describe("keyboard navigation", () => {
+ test("tab through join form fields in order", async ({ page }) => {
+ await page.goto("/join");
+ await page.waitForLoadState("networkidle");
// Focus the name field and tab through
- await page.locator('#join-name').focus()
- expect(await page.locator('#join-name').evaluate((el) => el === document.activeElement)).toBe(true)
+ await page.locator("#join-name").focus();
+ expect(
+ await page
+ .locator("#join-name")
+ .evaluate((el) => el === document.activeElement),
+ ).toBe(true);
- await page.keyboard.press('Tab')
+ await page.keyboard.press("Tab");
// Email field should receive focus next
- expect(await page.locator('#join-email').evaluate((el) => el === document.activeElement)).toBe(true)
- })
+ expect(
+ await page
+ .locator("#join-email")
+ .evaluate((el) => el === document.activeElement),
+ ).toBe(true);
+ });
- test('escape closes login modal', async ({ page }) => {
- await page.goto('/member/dashboard')
- // Wait for login modal to appear
- const modal = page.locator('text=Sign in to continue').or(page.locator('text=Sign in to your dashboard'))
- await expect(modal.first()).toBeVisible({ timeout: 10000 })
+ test("escape closes login modal", async ({ page }) => {
+ await page.goto("/member/dashboard");
- await page.keyboard.press('Escape')
+ // The page renders an inline "sign in required" wall for unauthenticated users
+ const signInBlock = page.locator("h2", { hasText: "Sign in required" });
+ await expect(signInBlock).toBeVisible({ timeout: 10000 });
+
+ // Click the Sign In button to open the login modal overlay
+ await page.locator("button", { hasText: "Sign In" }).click();
+
+ const modal = page.locator("text=Sign in to your dashboard");
+ await expect(modal.first()).toBeVisible({ timeout: 5000 });
+
+ await page.keyboard.press("Escape");
// Modal should close
- await expect(modal.first()).not.toBeVisible({ timeout: 5000 })
- })
-})
+ await expect(modal.first()).not.toBeVisible({ timeout: 5000 });
+ });
+});
diff --git a/e2e/admin-members.spec.js b/e2e/admin-members.spec.js
index 71bcb8b..28c5dcc 100644
--- a/e2e/admin-members.spec.js
+++ b/e2e/admin-members.spec.js
@@ -1,48 +1,53 @@
-import { test, expect } from './helpers/fixtures.js'
+import { test, expect } from "./helpers/fixtures.js";
-test.describe('Admin members page', () => {
- test('members list loads for admin', async ({ adminPage }) => {
- await adminPage.goto('/admin/members')
+test.describe("Admin members page", () => {
+ test("members list loads for admin", async ({ adminPage }) => {
+ await adminPage.goto("/admin/members");
- await expect(adminPage.locator('h1')).toHaveText('Members')
- await expect(adminPage.getByText('Manage members, contributions, and access')).toBeVisible()
- })
+ await expect(adminPage.locator("h1")).toHaveText("Members");
+ await expect(
+ adminPage.getByText("Manage members, contributions, and access"),
+ ).toBeVisible();
+ });
- test('search bar works', async ({ adminPage }) => {
- await adminPage.goto('/admin/members')
+ test("search bar works", async ({ adminPage }) => {
+ await adminPage.goto("/admin/members");
- const searchInput = adminPage.getByPlaceholder('Search members...')
- await expect(searchInput).toBeVisible()
+ const searchInput = adminPage.getByPlaceholder("Search members...");
+ await expect(searchInput).toBeVisible();
- await searchInput.fill('nonexistent-query-xyz')
+ await searchInput.fill("nonexistent-query-xyz");
// Page should not crash -- either shows filtered results or the empty state
await expect(
- adminPage.locator('table').or(adminPage.getByText('No members found matching your criteria'))
- ).toBeVisible()
- })
+ adminPage
+ .locator("table")
+ .or(adminPage.getByText("No members found matching your criteria")),
+ ).toBeVisible();
+ });
- test('non-admin redirect', async ({ browser }) => {
- const context = await browser.newContext()
- const page = await context.newPage()
+ test("non-admin redirect", async ({ browser }) => {
+ const context = await browser.newContext();
+ const page = await context.newPage();
- await page.goto('/admin/members')
+ await page.goto("/admin/members");
// Admin middleware redirects non-admin users to / or /members
- await page.waitForURL((url) => !url.pathname.startsWith('/admin'))
- expect(page.url()).not.toContain('/admin/members')
+ await page.waitForURL((url) => !url.pathname.startsWith("/admin"));
+ expect(page.url()).not.toContain("/admin/members");
- await context.close()
- })
+ await context.close();
+ });
- test('add member button opens modal', async ({ adminPage }) => {
- await adminPage.goto('/admin/members')
+ test("add member button opens modal", async ({ adminPage }) => {
+ await adminPage.goto("/admin/members");
+ await adminPage.waitForLoadState("networkidle"); // ensure Vue hydration is complete
- await adminPage.getByRole('button', { name: 'Add Member' }).click()
+ await adminPage.getByRole("button", { name: "Add Member" }).click();
// Modal should appear with the form heading and fields
- await expect(adminPage.getByText('Add New Member')).toBeVisible()
- await expect(adminPage.getByPlaceholder('Full name')).toBeVisible()
- await expect(adminPage.getByPlaceholder('email@example.com')).toBeVisible()
- })
-})
+ await expect(adminPage.getByText("Add New Member")).toBeVisible();
+ await expect(adminPage.getByPlaceholder("Full name")).toBeVisible();
+ await expect(adminPage.getByPlaceholder("email@example.com")).toBeVisible();
+ });
+});
diff --git a/e2e/coming-soon.spec.js b/e2e/coming-soon.spec.js
index 283bc5d..39c6e99 100644
--- a/e2e/coming-soon.spec.js
+++ b/e2e/coming-soon.spec.js
@@ -1,41 +1,45 @@
-import { test, expect } from '@playwright/test'
+import { test, expect } from "@playwright/test";
-test.describe('coming-soon page', () => {
- test('renders with heading and login form', async ({ page }) => {
- await page.goto('/coming-soon')
+test.describe("coming-soon page", () => {
+ test("renders with heading and login form", async ({ page }) => {
+ await page.goto("/coming-soon");
- await expect(page.locator('h1')).toContainText('Ghost Guild')
- await expect(page.locator('input[type="email"]')).toBeVisible()
- await expect(page.getByRole('button', { name: 'Send Magic Link' })).toBeVisible()
- })
+ await expect(page.locator("h1")).toContainText("Ghost Guild");
+ await expect(page.locator('input[type="email"]')).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Send Magic Link" }),
+ ).toBeVisible();
+ });
- test('shows "Coming Soon" text for unauthenticated visitors', async ({ page }) => {
- await page.goto('/coming-soon')
+ test('shows "Coming Soon" text for unauthenticated visitors', async ({
+ page,
+ }) => {
+ await page.goto("/coming-soon");
- await expect(page.getByText('Coming Soon')).toBeVisible()
- })
-})
+ await expect(page.getByText("Coming Soon")).toBeVisible();
+ });
+});
-test.describe('public routes accessible when gate is off', () => {
- test('home page loads', async ({ page }) => {
- await page.goto('/')
+test.describe("public routes accessible when gate is off", () => {
+ test("home page loads", async ({ page }) => {
+ await page.goto("/");
// Should not redirect to /coming-soon
- expect(page.url()).not.toContain('/coming-soon')
- await expect(page.getByText('Ghost Guild')).toBeVisible()
- })
+ expect(page.url()).not.toContain("/coming-soon");
+ await expect(page.locator("h1")).toContainText("Ghost Guild");
+ });
- test('events page loads', async ({ page }) => {
- await page.goto('/events')
+ test("events page loads", async ({ page }) => {
+ await page.goto("/events");
- expect(page.url()).not.toContain('/coming-soon')
- await expect(page.locator('h1')).toContainText('Events')
- })
+ expect(page.url()).not.toContain("/coming-soon");
+ await expect(page.locator("h1")).toContainText("Events");
+ });
- test('join page loads', async ({ page }) => {
- await page.goto('/join')
+ test("join page loads", async ({ page }) => {
+ await page.goto("/join");
- expect(page.url()).not.toContain('/coming-soon')
- await expect(page.locator('h1')).toContainText('Join Ghost Guild')
- })
-})
+ expect(page.url()).not.toContain("/coming-soon");
+ await expect(page.locator("h1")).toContainText("Join Ghost Guild");
+ });
+});
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 2083141..8d3be2f 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -9,7 +9,11 @@ export default defineNuxtConfig({
classSuffix: "",
},
app: {
- head: {},
+ head: {
+ htmlAttrs: { lang: "en" },
+ title: "Ghost Guild",
+ titleTemplate: "%s ยท Ghost Guild",
+ },
},
build: {
transpile: ["vue-cal"],
|