From a2fd9fa9757370d2790d2c58bf3b88110ebf216f Mon Sep 17 00:00:00 2001 From: Kevin Rutledge Date: Sun, 1 Mar 2026 17:04:37 -0800 Subject: [PATCH 1/2] feat: add groups home page with sidebar layout, mock portal redesign, and e2e tests --- .gitignore | 1 + README.md | 2 +- e2e/auth.setup.ts | 32 ++ e2e/groups.spec.ts | 408 ++++++++++++++++++ e2e/referral-database.spec.ts | 8 +- playwright.config.ts | 20 + src/actions/contact-group.ts | 40 ++ src/app/(dev)/dev/mock-portal/page.tsx | 234 ++++++++++ src/app/(protected)/error.tsx | 4 +- src/app/(protected)/groups/groups-content.tsx | 303 +++++++++++++ src/app/(protected)/groups/page.tsx | 12 + src/app/(protected)/layout.tsx | 19 +- .../(protected)/referral-database/page.tsx | 10 +- src/app/(public)/dev/mock-portal/page.tsx | 108 ----- src/app/team/suman/page.tsx | 24 +- .../layout/top-bar-action-context.tsx | 45 ++ src/components/layout/top-bar.tsx | 12 +- src/config/navigation.ts | 6 + src/lib/api/member-api.ts | 8 +- 19 files changed, 1144 insertions(+), 152 deletions(-) create mode 100644 e2e/auth.setup.ts create mode 100644 e2e/groups.spec.ts create mode 100644 src/app/(dev)/dev/mock-portal/page.tsx create mode 100644 src/app/(protected)/groups/groups-content.tsx create mode 100644 src/app/(protected)/groups/page.tsx delete mode 100644 src/app/(public)/dev/mock-portal/page.tsx create mode 100644 src/components/layout/top-bar-action-context.tsx diff --git a/.gitignore b/.gitignore index f4af6da..32fc944 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ /playwright-report/ /blob-report/ /playwright/.cache/ +/playwright/.auth/ # next.js /.next/ diff --git a/README.md b/README.md index 6216a15..194adbb 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ The platform will expand to include contact groups, allowing members to organize ### Team -The PRFC Connect team consists of 18 Cal Poly students. Over the course of about 9 months, we work as a team to deploy this web application. +The PRFC Connect team consists of 12 Cal Poly students. Over the course of about 9 months, we work as a team to deploy this web application. - [Austin Lee](https://www.linkedin.com/in/austinlee17/) - Project Manager - [Kevin Rutledge](https://www.linkedin.com/in/rutledge-kevin/) - Tech Lead diff --git a/e2e/auth.setup.ts b/e2e/auth.setup.ts new file mode 100644 index 0000000..2527414 --- /dev/null +++ b/e2e/auth.setup.ts @@ -0,0 +1,32 @@ +import { test as setup, expect } from "@playwright/test"; + +const ADMIN_AUTH_FILE = "playwright/.auth/admin.json"; +const MEMBER_AUTH_FILE = "playwright/.auth/member.json"; + +// Admin: ownerid 100001 (first mock member, in mockAdminIds) +// Member: ownerid 100003 (third mock member, not in mockAdminIds) +// Admin status is derived from mockAdminIds (first two members are admins) + +setup("authenticate as admin", async ({ page }) => { + await page.goto("/dev/mock-portal"); + + await page.getByText("Dev Tools").click(); + await page.getByLabel("Select Member").selectOption("100001"); + await page.getByRole("button", { name: "Login" }).click(); + + await page.waitForURL("/"); + await expect(page).toHaveURL("/"); + await page.context().storageState({ path: ADMIN_AUTH_FILE }); +}); + +setup("authenticate as member", async ({ page }) => { + await page.goto("/dev/mock-portal"); + + await page.getByText("Dev Tools").click(); + await page.getByLabel("Select Member").selectOption("100003"); + await page.getByRole("button", { name: "Login" }).click(); + + await page.waitForURL("/"); + await expect(page).toHaveURL("/"); + await page.context().storageState({ path: MEMBER_AUTH_FILE }); +}); diff --git a/e2e/groups.spec.ts b/e2e/groups.spec.ts new file mode 100644 index 0000000..b3d850d --- /dev/null +++ b/e2e/groups.spec.ts @@ -0,0 +1,408 @@ +import { test, expect, type Page, type Locator } from "@playwright/test"; + +function getOpenDialog(page: Page): Locator { + return page.locator('[role="dialog"][data-state="open"]'); +} + +function getOpenAlertDialog(page: Page): Locator { + return page.locator('[role="alertdialog"][data-state="open"]'); +} + +function getGroupCards(page: Page): Locator { + return page.locator("button").filter({ hasText: /\d+ members?/ }); +} + +test.describe("Groups page load", () => { + test("displays group cards and add card", async ({ page }) => { + await page.goto("/groups"); + + await expect(page.getByRole("button", { name: "Add new group" })).toBeVisible(); + }); + + test("shows correct heading for role", async ({ page }) => { + await page.goto("/groups"); + + const heading = page.getByRole("heading", { level: 1 }); + await expect(heading).toBeVisible(); + const text = await heading.textContent(); + expect(text === "Groups" || text === "My Groups").toBe(true); + }); +}); + +test.describe("Group detail modal", () => { + test("clicking a group card opens the detail modal", async ({ page }) => { + await page.goto("/groups"); + + const cards = getGroupCards(page); + const cardCount = await cards.count(); + test.skip(cardCount === 0, "no groups to test with"); + + await cards.first().click(); + + const dialog = getOpenDialog(page); + await expect(dialog).toBeVisible(); + await expect(dialog.getByLabel("Group Name")).toBeVisible(); + }); + + test("detail modal shows group name, description, and members section", async ({ page }) => { + await page.goto("/groups"); + + const cards = getGroupCards(page); + const cardCount = await cards.count(); + test.skip(cardCount === 0, "no groups to test with"); + + await cards.first().click(); + + const dialog = getOpenDialog(page); + await expect(dialog).toBeVisible(); + + await expect(dialog.getByLabel("Group Name")).toBeVisible(); + await expect(dialog.getByText("Members")).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Edit" })).toBeVisible(); + await expect(dialog.getByRole("button", { name: "View All" })).toBeVisible(); + }); + + test("detail modal closes via Escape key", async ({ page }) => { + await page.goto("/groups"); + + const cards = getGroupCards(page); + const cardCount = await cards.count(); + test.skip(cardCount === 0, "no groups to test with"); + + await cards.first().click(); + await expect(getOpenDialog(page)).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(getOpenDialog(page)).not.toBeVisible(); + }); + + test("Edit button transitions to edit modal with Save Changes", async ({ page }) => { + await page.goto("/groups"); + + const cards = getGroupCards(page); + const cardCount = await cards.count(); + test.skip(cardCount === 0, "no groups to test with"); + + await cards.first().click(); + const dialog = getOpenDialog(page); + await expect(dialog).toBeVisible(); + + await dialog.getByRole("button", { name: "Edit" }).click(); + + const editDialog = getOpenDialog(page); + await expect(editDialog.getByRole("button", { name: "Save Changes" })).toBeVisible(); + }); +}); + +test.describe("Group edit modal", () => { + async function openEditModal(page: Page) { + await page.goto("/groups"); + const cards = getGroupCards(page); + await cards.first().click(); + await expect(getOpenDialog(page)).toBeVisible(); + await getOpenDialog(page).getByRole("button", { name: "Edit" }).click(); + await expect(getOpenDialog(page).getByRole("button", { name: "Save Changes" })).toBeVisible(); + } + + test("pre-populates name and description from the group", async ({ page }) => { + await page.goto("/groups"); + const cards = getGroupCards(page); + const cardCount = await cards.count(); + test.skip(cardCount === 0, "no groups to test with"); + + await cards.first().click(); + const detailDialog = getOpenDialog(page); + await expect(detailDialog).toBeVisible(); + const groupName = await detailDialog.getByLabel("Group Name").inputValue(); + + await detailDialog.getByRole("button", { name: "Edit" }).click(); + const editDialog = getOpenDialog(page); + await expect(editDialog.getByRole("button", { name: "Save Changes" })).toBeVisible(); + + await expect(editDialog.getByLabel("Group Name")).toHaveValue(groupName); + }); + + test("submitting with empty name shows validation error", async ({ page }) => { + const cards = getGroupCards(page); + await page.goto("/groups"); + const cardCount = await cards.count(); + test.skip(cardCount === 0, "no groups to test with"); + + await openEditModal(page); + + const dialog = getOpenDialog(page); + await dialog.getByLabel("Group Name").clear(); + await dialog.getByRole("button", { name: "Save Changes" }).click(); + + await expect(dialog.getByText("Group name is required.")).toBeVisible(); + }); + + test("saving valid changes shows success toast and closes modal", async ({ page }) => { + const cards = getGroupCards(page); + await page.goto("/groups"); + const cardCount = await cards.count(); + test.skip(cardCount === 0, "no groups to test with"); + + await openEditModal(page); + + const dialog = getOpenDialog(page); + const nameInput = dialog.getByLabel("Group Name"); + const originalName = await nameInput.inputValue(); + + await nameInput.clear(); + await nameInput.fill(originalName + " Edited"); + await dialog.getByRole("button", { name: "Save Changes" }).click(); + + await expect(page.getByText("Group updated successfully")).toBeVisible(); + await expect(getOpenDialog(page)).not.toBeVisible(); + + await page.getByText(originalName + " Edited").click(); + await expect(getOpenDialog(page)).toBeVisible(); + await getOpenDialog(page).getByRole("button", { name: "Edit" }).click(); + await expect(getOpenDialog(page).getByRole("button", { name: "Save Changes" })).toBeVisible(); + const restoreInput = getOpenDialog(page).getByLabel("Group Name"); + await restoreInput.clear(); + await restoreInput.fill(originalName); + await getOpenDialog(page).getByRole("button", { name: "Save Changes" }).click(); + await expect(page.getByText("Group updated successfully")).toBeVisible(); + }); + + test("Delete button opens delete confirmation", async ({ page }) => { + const cards = getGroupCards(page); + await page.goto("/groups"); + const cardCount = await cards.count(); + test.skip(cardCount === 0, "no groups to test with"); + + await openEditModal(page); + + await getOpenDialog(page).getByRole("button", { name: "Delete" }).click(); + + const alertDialog = getOpenAlertDialog(page); + await expect(alertDialog).toBeVisible(); + await expect(alertDialog.getByText("Are you sure you want to delete this group?")).toBeVisible(); + }); + + test("Add Members button opens add members modal", async ({ page }) => { + const cards = getGroupCards(page); + await page.goto("/groups"); + const cardCount = await cards.count(); + test.skip(cardCount === 0, "no groups to test with"); + + await openEditModal(page); + + await getOpenDialog(page).getByRole("button", { name: "Add members to group" }).click(); + + const membersDialog = getOpenDialog(page); + await expect(membersDialog).toBeVisible(); + await expect(membersDialog.getByPlaceholder(/search/i)).toBeVisible(); + }); +}); + +test.describe("Delete group modal", () => { + async function openDeleteModal(page: Page) { + await page.goto("/groups"); + const cards = getGroupCards(page); + await cards.first().click(); + await expect(getOpenDialog(page)).toBeVisible(); + await getOpenDialog(page).getByRole("button", { name: "Edit" }).click(); + await expect(getOpenDialog(page).getByRole("button", { name: "Save Changes" })).toBeVisible(); + await getOpenDialog(page).getByRole("button", { name: "Delete" }).click(); + await expect(getOpenAlertDialog(page)).toBeVisible(); + } + + test("cancel returns to edit modal", async ({ page }) => { + const cards = getGroupCards(page); + await page.goto("/groups"); + const cardCount = await cards.count(); + test.skip(cardCount === 0, "no groups to test with"); + + await openDeleteModal(page); + + await getOpenAlertDialog(page).getByRole("button", { name: "Cancel" }).click(); + + await expect(getOpenAlertDialog(page)).not.toBeVisible(); + const editDialog = getOpenDialog(page); + await expect(editDialog).toBeVisible(); + await expect(editDialog.getByRole("button", { name: "Save Changes" })).toBeVisible(); + }); +}); + +test.describe("Create group modal", () => { + test("add card opens create modal", async ({ page }) => { + await page.goto("/groups"); + + await page.getByRole("button", { name: "Add new group" }).click(); + + const dialog = getOpenDialog(page); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText("Create Contact Group")).toBeVisible(); + }); + + test("TopBar New Group button opens create modal", async ({ page }) => { + await page.goto("/groups"); + + await page.getByRole("button", { name: /New Group/ }).click(); + + const dialog = getOpenDialog(page); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText("Create Contact Group")).toBeVisible(); + }); + + test("Create Group button is disabled when name is empty", async ({ page }) => { + await page.goto("/groups"); + await page.getByRole("button", { name: "Add new group" }).click(); + + const dialog = getOpenDialog(page); + await expect(dialog.getByRole("button", { name: "Create Group" })).toBeDisabled(); + }); + + test("creating a group shows success toast and new card appears", async ({ page }) => { + await page.goto("/groups"); + + const uniqueName = `E2E Test Group ${Date.now()}`; + + await page.getByRole("button", { name: "Add new group" }).click(); + + const dialog = getOpenDialog(page); + await dialog.getByLabel("Group Name").fill(uniqueName); + await dialog.getByLabel("Description (Optional)").fill("Created by e2e test"); + await dialog.getByRole("button", { name: "Create Group" }).click(); + + await expect(page.getByText("Group created successfully")).toBeVisible(); + await expect(getOpenDialog(page)).not.toBeVisible(); + + await expect(page.getByText(uniqueName)).toBeVisible(); + + await page.getByText(uniqueName).click(); + await expect(getOpenDialog(page)).toBeVisible(); + await getOpenDialog(page).getByRole("button", { name: "Edit" }).click(); + await expect(getOpenDialog(page).getByRole("button", { name: "Save Changes" })).toBeVisible(); + await getOpenDialog(page).getByRole("button", { name: "Delete" }).click(); + await expect(getOpenAlertDialog(page)).toBeVisible(); + await getOpenAlertDialog(page).getByRole("button", { name: "Confirm" }).click(); + await expect(page.getByText("Group deleted successfully")).toBeVisible(); + }); + + test("cancel button closes create modal and clears form", async ({ page }) => { + await page.goto("/groups"); + + await page.getByRole("button", { name: "Add new group" }).click(); + const dialog = getOpenDialog(page); + await dialog.getByLabel("Group Name").fill("Should be cleared"); + + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(getOpenDialog(page)).not.toBeVisible(); + + await page.getByRole("button", { name: "Add new group" }).click(); + await expect(getOpenDialog(page).getByLabel("Group Name")).toHaveValue(""); + }); +}); + +test.describe("Add members modal", () => { + async function openAddMembersModal(page: Page) { + await page.goto("/groups"); + const cards = getGroupCards(page); + await cards.first().click(); + await expect(getOpenDialog(page)).toBeVisible(); + await getOpenDialog(page).getByRole("button", { name: "Edit" }).click(); + await expect(getOpenDialog(page).getByRole("button", { name: "Save Changes" })).toBeVisible(); + await getOpenDialog(page).getByRole("button", { name: "Add members to group" }).click(); + await expect(getOpenDialog(page).getByPlaceholder(/search/i)).toBeVisible(); + } + + test("shows member list with checkboxes", async ({ page }) => { + const cards = getGroupCards(page); + await page.goto("/groups"); + const cardCount = await cards.count(); + test.skip(cardCount === 0, "no groups to test with"); + + await openAddMembersModal(page); + + const dialog = getOpenDialog(page); + const checkboxes = dialog.getByRole("checkbox"); + const checkboxCount = await checkboxes.count(); + expect(checkboxCount).toBeGreaterThan(0); + }); + + test("search filters members by name", async ({ page }) => { + const cards = getGroupCards(page); + await page.goto("/groups"); + const cardCount = await cards.count(); + test.skip(cardCount === 0, "no groups to test with"); + + await openAddMembersModal(page); + + const dialog = getOpenDialog(page); + const searchInput = dialog.getByPlaceholder(/search/i); + + const initialCount = await dialog.getByRole("checkbox").count(); + + await searchInput.fill("zzz_nonexistent_name"); + + await expect(async () => { + const filteredCount = await dialog.getByRole("checkbox").count(); + expect(filteredCount).toBeLessThan(initialCount); + }).toPass({ timeout: 3000 }); + }); + + test("saving with no new selections returns to edit modal", async ({ page }) => { + const cards = getGroupCards(page); + await page.goto("/groups"); + const cardCount = await cards.count(); + test.skip(cardCount === 0, "no groups to test with"); + + await openAddMembersModal(page); + + await getOpenDialog(page).getByRole("button", { name: "Save" }).click(); + + const editDialog = getOpenDialog(page); + await expect(editDialog.getByRole("button", { name: "Save Changes" })).toBeVisible(); + }); +}); + +test.describe("Full modal navigation chains", () => { + test("card → detail → edit → delete → cancel → back to edit → close", async ({ page }) => { + await page.goto("/groups"); + + const cards = getGroupCards(page); + const cardCount = await cards.count(); + test.skip(cardCount === 0, "no groups to test with"); + + await cards.first().click(); + await expect(getOpenDialog(page)).toBeVisible({ timeout: 10000 }); + await expect(getOpenDialog(page).getByRole("button", { name: "Edit" })).toBeVisible(); + + await getOpenDialog(page).getByRole("button", { name: "Edit" }).click(); + await expect(getOpenDialog(page).getByRole("button", { name: "Save Changes" })).toBeVisible(); + + await getOpenDialog(page).getByRole("button", { name: "Delete" }).click(); + await expect(getOpenAlertDialog(page)).toBeVisible(); + + await getOpenAlertDialog(page).getByRole("button", { name: "Cancel" }).click(); + await expect(getOpenAlertDialog(page)).not.toBeVisible(); + await expect(getOpenDialog(page).getByRole("button", { name: "Save Changes" })).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(getOpenDialog(page)).not.toBeVisible(); + }); + + test("card → detail → edit → add members → save → back to edit", async ({ page }) => { + await page.goto("/groups"); + + const cards = getGroupCards(page); + const cardCount = await cards.count(); + test.skip(cardCount === 0, "no groups to test with"); + + await cards.first().click(); + await expect(getOpenDialog(page)).toBeVisible(); + + await getOpenDialog(page).getByRole("button", { name: "Edit" }).click(); + await expect(getOpenDialog(page).getByRole("button", { name: "Save Changes" })).toBeVisible(); + + await getOpenDialog(page).getByRole("button", { name: "Add members to group" }).click(); + await expect(getOpenDialog(page).getByPlaceholder(/search/i)).toBeVisible(); + + await getOpenDialog(page).getByRole("button", { name: "Save" }).click(); + await expect(getOpenDialog(page).getByRole("button", { name: "Save Changes" })).toBeVisible(); + }); +}); diff --git a/e2e/referral-database.spec.ts b/e2e/referral-database.spec.ts index d4a73b4..2876f93 100644 --- a/e2e/referral-database.spec.ts +++ b/e2e/referral-database.spec.ts @@ -2,9 +2,9 @@ import { test, expect } from "@playwright/test"; async function loginAsAdmin(page: import("@playwright/test").Page) { await page.goto("/dev/mock-portal"); - await page.getByLabel("Owner ID").fill("100184"); - await page.getByLabel("Admin access").check(); - await page.getByRole("button", { name: "Enter PRFC Connect" }).click(); + await page.getByText("Dev Tools").click(); + await page.getByLabel("Select Member").selectOption("100001"); + await page.getByRole("button", { name: "Login" }).click(); await page.waitForURL("/"); await page.goto("/referral-database"); } @@ -25,7 +25,7 @@ test.describe("Referral Database Page", () => { test("search filters table rows", async ({ page }) => { await loginAsAdmin(page); - const searchInput = page.getByPlaceholder(/search/i); + const searchInput = page.getByRole("textbox", { name: "Search referrals" }); await searchInput.fill("test"); await expect(page.getByRole("table")).toBeVisible(); diff --git a/playwright.config.ts b/playwright.config.ts index 23309ec..72b2b1c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -14,9 +14,29 @@ export default defineConfig({ trace: "on-first-retry", }, projects: [ + { name: "setup", testMatch: /.*\.setup\.ts/ }, { name: "chromium", use: { ...devices["Desktop Chrome"] }, + testIgnore: /.*groups.*\.spec\.ts/, + }, + { + name: "groups-admin", + use: { + ...devices["Desktop Chrome"], + storageState: "playwright/.auth/admin.json", + }, + testMatch: /.*groups.*\.spec\.ts/, + dependencies: ["setup"], + }, + { + name: "groups-member", + use: { + ...devices["Desktop Chrome"], + storageState: "playwright/.auth/member.json", + }, + testMatch: /.*groups.*\.spec\.ts/, + dependencies: ["setup"], }, ], webServer: { diff --git a/src/actions/contact-group.ts b/src/actions/contact-group.ts index 1760b7b..81c69f8 100644 --- a/src/actions/contact-group.ts +++ b/src/actions/contact-group.ts @@ -15,6 +15,8 @@ import { updateGroup, deleteGroup, isGroupOwner, + getGroupById, + enrichGroupMembers, addMembersToGroup, removeMemberFromGroup, updateMemberNotifications, @@ -24,6 +26,44 @@ import { transformError } from "@/utils/errors"; import type { ActionResult } from "@/lib/action-types"; import type { MessageResult } from "@/services/message"; +export interface EnrichedGroupData { + id: number; + name: string; + description: string | null; + members: Array<{ memberId: number; ownername: string }>; + memberCount: number; +} + +export async function fetchEnrichedGroup(groupId: number): Promise> { + try { + const session = await verifySession(); + + if (!session.isAdmin && !(await isGroupOwner(groupId, session.ownerid))) { + return { success: false, error: "You do not have permission to view this group" }; + } + + const group = await getGroupById(groupId); + const enriched = await enrichGroupMembers(group); + + return { + success: true, + data: { + id: enriched.id, + name: enriched.name, + description: enriched.description, + members: enriched.members.map((m) => ({ + memberId: m.memberId, + ownername: m.ownername, + })), + memberCount: enriched.memberCount, + }, + }; + } catch (error) { + const appError = transformError(error); + return { success: false, error: appError.message }; + } +} + export async function createContactGroup(formData: FormData): Promise> { try { const session = await verifySession(); diff --git a/src/app/(dev)/dev/mock-portal/page.tsx b/src/app/(dev)/dev/mock-portal/page.tsx new file mode 100644 index 0000000..ed18063 --- /dev/null +++ b/src/app/(dev)/dev/mock-portal/page.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import { mockMembers, isMockAdmin } from "@/lib/mock-members"; + +export default function MockPortalPage() { + const [selectedMember, setSelectedMember] = useState(mockMembers[0]); + const [email, setEmail] = useState(mockMembers[0].owneremail); + const [password, setPassword] = useState("password"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + if (process.env.NODE_ENV === "production") { + return ( +
+

Not available in production

+
+ ); + } + + function handleMemberSelect(ownerid: number) { + const member = mockMembers.find((m) => m.ownerid === ownerid); + if (member) { + setSelectedMember(member); + setEmail(member.owneremail); + setError(""); + } + } + + async function handleLogin(e: React.FormEvent) { + e.preventDefault(); + setError(""); + + if (!email || !password) { + setError("You must enter an email address and password."); + return; + } + + const member = mockMembers.find((m) => m.owneremail.toLowerCase() === email.toLowerCase()); + if (!member) { + setError("No member found with that email address."); + return; + } + + setLoading(true); + + const res = await fetch("/api/dev/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ownerid: member.ownerid, isAdmin: isMockAdmin(member.ownerid) }), + }); + + if (!res.ok) { + setLoading(false); + setError("Failed to generate authentication token."); + return; + } + + const { token } = await res.json(); + + const form = document.createElement("form"); + form.method = "POST"; + form.action = "/api/auth/callback"; + + const input = document.createElement("input"); + input.type = "hidden"; + input.name = "token"; + input.value = token; + form.appendChild(input); + + document.body.appendChild(form); + form.submit(); + } + + const navItems = ["The Co-op", "News & Events", "Get Involved", "Join Now", "Donate", "Contact Us", "FAQ", "Login"]; + + return ( +
+
+
+ Paso Robles Food Co-op Logo +
+

For help or info, please contact us:

+

+ E-Mail:{" "} + + info@pasofoodcooperative.com + +

+
+
+
+ + + +
+
+

+ You must be logged in to view this page. +

+ +

+ Login to your Paso Food Co-op Member Account +

+ +
+
+ + { + setEmail(e.target.value); + if (error) setError(""); + }} + className="w-full rounded border border-prfc-border px-3 py-2 text-sm focus:border-prfc-brown focus:outline-none focus:ring-1 focus:ring-prfc-brown" + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full rounded border border-prfc-border px-3 py-2 text-sm focus:border-prfc-brown focus:outline-none focus:ring-1 focus:ring-prfc-brown" + /> +
+ + {error ?

{error}

: null} + + +
+ + +
+ +
+
+ + Dev Tools - Quick Select Member + + +
+ + +
+
+ Name: {selectedMember.ownername} +
+
+ Email: {selectedMember.owneremail} +
+
+ Owner ID: {selectedMember.ownerid} +
+
+ Role: {isMockAdmin(selectedMember.ownerid) ? "Admin" : "Member"} +
+
+
+
+
+
+ +
+

+ {navItems.map((item, i) => ( + + + {item} + + {i < navItems.length - 1 ? " | " : ""} + + ))} +

+

+ Copyright © 2013-2026 by Paso Robles Food Cooperative, Inc. All Rights Reserved. +

+
+
+ ); +} diff --git a/src/app/(protected)/error.tsx b/src/app/(protected)/error.tsx index 83d84e4..a0831ba 100644 --- a/src/app/(protected)/error.tsx +++ b/src/app/(protected)/error.tsx @@ -9,7 +9,7 @@ export default function ProtectedError({ error, reset }: { error: Error & { dige }, [error]); return ( -
+

Database Error

Failed to load referral database.

@@ -28,6 +28,6 @@ export default function ProtectedError({ error, reset }: { error: Error & { dige
-
+ ); } diff --git a/src/app/(protected)/groups/groups-content.tsx b/src/app/(protected)/groups/groups-content.tsx new file mode 100644 index 0000000..eb8502a --- /dev/null +++ b/src/app/(protected)/groups/groups-content.tsx @@ -0,0 +1,303 @@ +"use client"; + +import { useCallback, useState, useTransition } from "react"; +import { toast } from "sonner"; + +import { EntityCard } from "@/components/groups/entity-card"; +import { GroupDetailViewModal } from "@/components/groups/group-detail-view-modal"; +import { GroupEditModal } from "@/components/groups/group-edit-modal"; +import { CreateGroupModal } from "@/components/groups/create-group-modal"; +import { DeleteGroupModal } from "@/components/groups/delete-group-modal"; +import { AddMembersModal } from "@/components/groups/add-members-modal"; +import type { MemberRow } from "@/components/groups/add-members-modal"; +import { useSetTopBarAction } from "@/components/layout/top-bar-action-context"; +import { + fetchEnrichedGroup, + createContactGroup, + updateContactGroup, + deleteContactGroup, + addMembers, +} from "@/actions/contact-group"; +import type { EnrichedGroupData } from "@/actions/contact-group"; +import type { GroupWithCount } from "@/services/contact-group"; + +const EMPTY_GROUP: EnrichedGroupData = { + id: 0, + name: "", + description: null, + members: [], + memberCount: 0, +}; + +function buildGroupFormData(data: { name: string; description: string | null }): FormData { + const formData = new FormData(); + formData.set("name", data.name); + if (data.description) { + formData.set("description", data.description); + } + return formData; +} + +interface GroupsContentProps { + groups: GroupWithCount[]; + isAdmin: boolean; + ownerId: number; +} + +type ModalState = + | { type: "closed" } + | { type: "detail"; group: EnrichedGroupData } + | { type: "edit"; group: EnrichedGroupData } + | { type: "create" } + | { type: "delete"; group: EnrichedGroupData } + | { type: "addMembers"; group: EnrichedGroupData; memberRows: MemberRow[] }; + +export function GroupsContent({ groups, isAdmin, ownerId }: GroupsContentProps) { + const [modal, setModal] = useState({ type: "closed" }); + const [isPending, startTransition] = useTransition(); + const [loadingGroupId, setLoadingGroupId] = useState(null); + + const openCreateModal = useCallback(() => { + setModal({ type: "create" }); + }, []); + + useSetTopBarAction("New Group", openCreateModal); + + const handleCardClick = useCallback((groupId: number) => { + setLoadingGroupId(groupId); + startTransition(async () => { + const result = await fetchEnrichedGroup(groupId); + setLoadingGroupId(null); + if (result.success && result.data) { + setModal({ type: "detail", group: result.data }); + } else { + toast.error(result.error ?? "Failed to load group details"); + } + }); + }, []); + + const handleEdit = useCallback(() => { + if (modal.type === "detail") { + setModal({ type: "edit", group: modal.group }); + } + }, [modal]); + + const handleSave = useCallback( + (data: { name: string; description: string | null }) => { + if (modal.type !== "edit") return; + const groupId = modal.group.id; + + startTransition(async () => { + const result = await updateContactGroup(groupId, buildGroupFormData(data)); + if (result.success) { + toast.success("Group updated successfully"); + setModal({ type: "closed" }); + } else { + toast.error(result.error ?? "Failed to update group"); + } + }); + }, + [modal], + ); + + const handleDelete = useCallback(() => { + if (modal.type === "edit") { + setModal({ type: "delete", group: modal.group }); + } + }, [modal]); + + const handleConfirmDelete = useCallback(() => { + if (modal.type !== "delete") return; + const groupId = modal.group.id; + + startTransition(async () => { + const result = await deleteContactGroup(groupId); + if (result.success) { + toast.success("Group deleted successfully"); + setModal({ type: "closed" }); + } else { + toast.error(result.error ?? "Failed to delete group"); + } + }); + }, [modal]); + + const handleAddMembersOpen = useCallback(() => { + if (modal.type !== "edit") return; + const currentGroup = modal.group; + + startTransition(async () => { + try { + const res = await fetch("/api/members"); + if (!res.ok) { + toast.error("Failed to load members"); + return; + } + + const allMembers: Array<{ ownerid: number; ownername: string }> = await res.json(); + const currentMemberIds = new Set(currentGroup.members.map((m) => m.memberId)); + + const memberRows: MemberRow[] = allMembers.map((m) => ({ + memberId: m.ownerid, + ownername: m.ownername, + isOwner: m.ownerid === ownerId, + isSelected: currentMemberIds.has(m.ownerid), + })); + + setModal({ type: "addMembers", group: currentGroup, memberRows }); + } catch { + toast.error("Failed to load members"); + } + }); + }, [modal, ownerId]); + + const handleSelectionChange = useCallback( + (memberId: number, selected: boolean) => { + if (modal.type !== "addMembers") return; + + setModal({ + ...modal, + memberRows: modal.memberRows.map((row) => (row.memberId === memberId ? { ...row, isSelected: selected } : row)), + }); + }, + [modal], + ); + + const handleConfirmAddMembers = useCallback(() => { + if (modal.type !== "addMembers") return; + const groupId = modal.group.id; + const currentGroup = modal.group; + const currentMemberIds = new Set(currentGroup.members.map((m) => m.memberId)); + + const newMembers = modal.memberRows + .filter((row) => row.isSelected && !currentMemberIds.has(row.memberId)) + .map((row) => ({ memberId: row.memberId })); + + if (newMembers.length === 0) { + setModal({ type: "edit", group: currentGroup }); + return; + } + + startTransition(async () => { + const result = await addMembers({ groupId, members: newMembers }); + if (result.success) { + toast.success(`Added ${result.data?.count ?? newMembers.length} member(s)`); + const refreshed = await fetchEnrichedGroup(groupId); + if (refreshed.success && refreshed.data) { + setModal({ type: "edit", group: refreshed.data }); + } else { + setModal({ type: "closed" }); + } + } else { + toast.error(result.error ?? "Failed to add members"); + } + }); + }, [modal]); + + const handleCreateSubmit = useCallback((data: { name: string; description: string | null }) => { + startTransition(async () => { + const result = await createContactGroup(buildGroupFormData(data)); + if (result.success) { + toast.success("Group created successfully"); + setModal({ type: "closed" }); + } else { + toast.error(result.error ?? "Failed to create group"); + } + }); + }, []); + + const closeModal = useCallback(() => { + setModal({ type: "closed" }); + }, []); + + const handleDeleteCancel = useCallback(() => { + if (modal.type === "delete") { + setModal({ type: "edit", group: modal.group }); + } + }, [modal]); + + const handleAddMembersCancel = useCallback(() => { + if (modal.type === "addMembers") { + setModal({ type: "edit", group: modal.group }); + } + }, [modal]); + + return ( +
+

{isAdmin ? "Groups" : "My Groups"}

+ +
+ {groups.map((group) => ( +
+ handleCardClick(group.id)} + /> + {loadingGroupId === group.id ? ( +
+
+
+ ) : null} +
+ ))} + +
+ + { + if (!open) closeModal(); + }} + group={modal.type === "detail" ? modal.group : EMPTY_GROUP} + onEdit={handleEdit} + onViewAllMembers={handleEdit} + /> + + { + if (!open) closeModal(); + }} + group={modal.type === "edit" ? modal.group : EMPTY_GROUP} + onSave={handleSave} + onDelete={handleDelete} + onAddMembers={handleAddMembersOpen} + isSubmitting={modal.type === "edit" && isPending} + /> + + { + if (!open) closeModal(); + }} + onSubmit={handleCreateSubmit} + isSubmitting={modal.type === "create" && isPending} + /> + + { + if (!open) handleDeleteCancel(); + }} + groupName={modal.type === "delete" ? modal.group.name : ""} + onConfirm={handleConfirmDelete} + isDeleting={modal.type === "delete" && isPending} + /> + + { + if (!open) handleAddMembersCancel(); + }} + groupName={modal.type === "addMembers" ? modal.group.name : ""} + members={modal.type === "addMembers" ? modal.memberRows : []} + onSelectionChange={handleSelectionChange} + onConfirm={handleConfirmAddMembers} + isSubmitting={modal.type === "addMembers" && isPending} + /> +
+ ); +} diff --git a/src/app/(protected)/groups/page.tsx b/src/app/(protected)/groups/page.tsx new file mode 100644 index 0000000..d1bef2a --- /dev/null +++ b/src/app/(protected)/groups/page.tsx @@ -0,0 +1,12 @@ +import { getSessionWithName } from "@/lib/dal"; +import { getAllGroups, getGroupsByOwner } from "@/services/contact-group"; +import { GroupsContent } from "./groups-content"; + +export default async function GroupsPage() { + const session = await getSessionWithName(); + const isAdmin = session.isAdmin; + + const groups = isAdmin ? await getAllGroups() : await getGroupsByOwner(session.ownerid); + + return ; +} diff --git a/src/app/(protected)/layout.tsx b/src/app/(protected)/layout.tsx index 6b82088..41d073d 100644 --- a/src/app/(protected)/layout.tsx +++ b/src/app/(protected)/layout.tsx @@ -1,10 +1,17 @@ -import { Header } from "@/components/layout/header"; +import { getSessionWithName } from "@/lib/dal"; +import { TopBarActionProvider } from "@/components/layout/top-bar-action-context"; +import { TopBar } from "@/components/layout/top-bar"; +import { Sidebar } from "@/components/layout/sidebar"; + +export default async function ProtectedLayout({ children }: { children: React.ReactNode }) { + const session = await getSessionWithName(); + const userRole = session.isAdmin ? "Admin Manager" : "Member"; -export default function ProtectedLayout({ children }: { children: React.ReactNode }) { return ( - <> -
- {children} - + + + +
{children}
+
); } diff --git a/src/app/(protected)/referral-database/page.tsx b/src/app/(protected)/referral-database/page.tsx index 9c3badc..e182960 100644 --- a/src/app/(protected)/referral-database/page.tsx +++ b/src/app/(protected)/referral-database/page.tsx @@ -2,11 +2,9 @@ import { ReferralDataGrid } from "@/components/referral/referral-data-grid"; export default function ReferralDatabasePage() { return ( -
-
-

Referral History

- -
-
+
+

Referral History

+ +
); } diff --git a/src/app/(public)/dev/mock-portal/page.tsx b/src/app/(public)/dev/mock-portal/page.tsx deleted file mode 100644 index 5ed43a4..0000000 --- a/src/app/(public)/dev/mock-portal/page.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { mockMembers, isMockAdmin } from "@/lib/mock-members"; - -export default function MockPortalPage() { - const [selectedMember, setSelectedMember] = useState(mockMembers[0]); - const [isAdmin, setIsAdmin] = useState(isMockAdmin(100001)); - const [loading, setLoading] = useState(false); - - if (process.env.NODE_ENV === "production") { - return ( -
-

Not available in production

-
- ); - } - - async function handleLogin() { - setLoading(true); - - const res = await fetch("/api/dev/token", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ownerid: selectedMember.ownerid, isAdmin }), - }); - - if (!res.ok) { - setLoading(false); - return; - } - - const { token } = await res.json(); - - const form = document.createElement("form"); - form.method = "POST"; - form.action = "/api/auth/callback"; - - const input = document.createElement("input"); - input.type = "hidden"; - input.name = "token"; - input.value = token; - form.appendChild(input); - - document.body.appendChild(form); - form.submit(); - } - - return ( -
-

Mock PRFC Portal

-

- Simulates the PRFC member portal token handoff. In production, members click through from - pasofoodcooperative.coop/accounts/. -

- -
- - -
-
- Name: {selectedMember.ownername} -
-
- Email: {selectedMember.owneremail} -
-
- Phone: {selectedMember.ownerphone} -
- {selectedMember.owneraltphone && ( -
- Alt Phone: {selectedMember.owneraltphone} -
- )} -
- - - - -
-
- ); -} diff --git a/src/app/team/suman/page.tsx b/src/app/team/suman/page.tsx index 89848b0..aff88d8 100644 --- a/src/app/team/suman/page.tsx +++ b/src/app/team/suman/page.tsx @@ -2,25 +2,23 @@ import { Sidebar } from "@/components/layout/sidebar"; import { TopBar } from "@/components/layout/top-bar"; +import { TopBarActionProvider } from "@/components/layout/top-bar-action-context"; export default function SumanPage() { return (
- alert("Action clicked")} - /> + + - + -
-

Top Bar + Sidebar Preview

-

- Top bar spans full width. Sidebar is fixed on the left below it (hidden on mobile). -

-
+
+

Top Bar + Sidebar Preview

+

+ Top bar spans full width. Sidebar is fixed on the left below it (hidden on mobile). +

+
+
); } diff --git a/src/components/layout/top-bar-action-context.tsx b/src/components/layout/top-bar-action-context.tsx new file mode 100644 index 0000000..b2bbfa4 --- /dev/null +++ b/src/components/layout/top-bar-action-context.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { createContext, useCallback, useContext, useEffect, useState } from "react"; +import type { ReactNode } from "react"; + +interface TopBarAction { + label: string; + onClick: () => void; +} + +interface TopBarActionContextValue { + action: TopBarAction | null; + setAction: (action: TopBarAction | null) => void; +} + +const TopBarActionContext = createContext(null); + +export function TopBarActionProvider({ children }: { children: ReactNode }) { + const [action, setAction] = useState(null); + + return {children}; +} + +export function useTopBarAction(): TopBarAction | null { + const ctx = useContext(TopBarActionContext); + if (!ctx) { + throw new Error("useTopBarAction must be used within a TopBarActionProvider"); + } + return ctx.action; +} + +export function useSetTopBarAction(label: string, onClick: () => void): void { + const ctx = useContext(TopBarActionContext); + if (!ctx) { + throw new Error("useSetTopBarAction must be used within a TopBarActionProvider"); + } + + const { setAction } = ctx; + const stableOnClick = useCallback(() => onClick(), [onClick]); + + useEffect(() => { + setAction({ label, onClick: stableOnClick }); + return () => setAction(null); + }, [label, stableOnClick, setAction]); +} diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx index 5dfc6b1..88fbf59 100644 --- a/src/components/layout/top-bar.tsx +++ b/src/components/layout/top-bar.tsx @@ -9,15 +9,15 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { getAvatarColor, getInitials } from "@/utils/avatar"; +import { useTopBarAction } from "@/components/layout/top-bar-action-context"; interface TopBarProps { userName: string; userRole: "Admin Manager" | "Member"; - actionLabel?: string; - onActionClick?: () => void; } -export function TopBar({ userName, userRole, actionLabel, onActionClick }: TopBarProps) { +export function TopBar({ userName, userRole }: TopBarProps) { + const action = useTopBarAction(); const initials = getInitials(userName); const avatarColor = getAvatarColor(userName); @@ -45,14 +45,14 @@ export function TopBar({ userName, userRole, actionLabel, onActionClick }: TopBa
- {actionLabel ? ( + {action ? ( ) : null} diff --git a/src/config/navigation.ts b/src/config/navigation.ts index 96c8c96..ed22380 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -14,12 +14,18 @@ export const NAV_CONFIG: Record = { member: [ { label: "Home", href: "/" }, { label: "My Groups", href: "/groups" }, + { label: "Messages", href: "/messages" }, + { label: "Events", href: "/events" }, { label: "Referral", href: "/referral" }, + { label: "Settings", href: "/settings" }, ], admin: [ { label: "Home", href: "/" }, { label: "Groups", href: "/groups" }, + { label: "Messages", href: "/messages" }, + { label: "Events", href: "/events" }, { label: "Referral Database", href: "/referral-database" }, { label: "Broadcast", href: "/broadcast" }, + { label: "Settings", href: "/settings" }, ], }; diff --git a/src/lib/api/member-api.ts b/src/lib/api/member-api.ts index 996c2f3..85552c9 100644 --- a/src/lib/api/member-api.ts +++ b/src/lib/api/member-api.ts @@ -9,12 +9,8 @@ export interface MemberSummary { } async function getMockMemberDetails(memberIds: number[]): Promise { - return memberIds.map((id) => ({ - ownerid: id, - ownername: `Member ${id}`, - owneremail: `member${id}@example.com`, - ownerphone: `+1555000${String(id).padStart(4, "0")}`, - })); + const { findMemberById } = await import("@/lib/mock-members"); + return memberIds.map((id) => findMemberById(id)).filter((m): m is MockMember => m !== undefined); } async function getRealMemberDetails(_memberIds: number[]): Promise { From 0fbdcdf276c19b43201e5cfb7140d18f9cf5695a Mon Sep 17 00:00:00 2001 From: Kevin Rutledge Date: Sun, 1 Mar 2026 17:16:55 -0800 Subject: [PATCH 2/2] refactor: replace view all members navigation with inline scrollable list in detail modal --- src/app/(protected)/groups/groups-content.tsx | 1 - src/app/team/phan/page.tsx | 8 +- .../groups/group-detail-view-modal.tsx | 88 ++++++++++++------- 3 files changed, 57 insertions(+), 40 deletions(-) diff --git a/src/app/(protected)/groups/groups-content.tsx b/src/app/(protected)/groups/groups-content.tsx index eb8502a..20853dc 100644 --- a/src/app/(protected)/groups/groups-content.tsx +++ b/src/app/(protected)/groups/groups-content.tsx @@ -252,7 +252,6 @@ export function GroupsContent({ groups, isAdmin, ownerId }: GroupsContentProps) }} group={modal.type === "detail" ? modal.group : EMPTY_GROUP} onEdit={handleEdit} - onViewAllMembers={handleEdit} /> setOpen(true)}>Open Detail View - alert("Edit clicked")} - onViewAllMembers={() => alert("View all members clicked")} - /> + alert("Edit clicked")} />
); } diff --git a/src/components/groups/group-detail-view-modal.tsx b/src/components/groups/group-detail-view-modal.tsx index b338789..127fb0e 100644 --- a/src/components/groups/group-detail-view-modal.tsx +++ b/src/components/groups/group-detail-view-modal.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -18,18 +19,18 @@ interface GroupDetailViewModalProps { memberCount: number; }; onEdit: () => void; - onViewAllMembers: () => void; } -export function GroupDetailViewModal({ - open, - onOpenChange, - group, - onEdit, - onViewAllMembers, -}: GroupDetailViewModalProps) { +export function GroupDetailViewModal({ open, onOpenChange, group, onEdit }: GroupDetailViewModalProps) { + const [showAllMembers, setShowAllMembers] = useState(false); + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) setShowAllMembers(false); + onOpenChange(nextOpen); + }; + return ( - + {group.name} @@ -63,35 +64,58 @@ export function GroupDetailViewModal({ Members -
- {group.members.slice(0, 8).map((member) => ( - -
+ )}