diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..723654dc
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,21 @@
+API_KEY= anything
+AUTH_SECRET= can be anything
+DATABASE_URL= need to set up a postgres instance and use a uri that points to it
+EMAIL_ADMIN_DESTINATIONS= comma separated email addresses
+EMAIL_FROM= email address
+EMAIL_SERVER= email uri (ethereal mail)
+GCP_DATA_PROJECT_ID= need to set up a GCP project (free)
+GOOGLE_LOGO_BUCKET_BUCKET_NAME= create a GCP bucket
+GOOGLE_LOGO_BUCKET_CLIENT_EMAIL= create a service account
+GOOGLE_LOGO_BUCKET_PRIVATE_KEY= private key for the service account
+GOOGLE_LOGO_BUCKET_PROJECT_ID= GCP project id
+GOOGLE_SERVICE_ACCOUNT_CLIENT_EMAIL= Not needed unless running migration
+GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY= Not needed unless running migration
+GOOGLE_SHEET_ID= Not needed unless running migration
+GOOGLE_SHEET_ID_GRAVITY_FORMS= Not needed unless running migration
+NEXT_PUBLIC_CHANNEL=local
+NEXT_PUBLIC_GA_MEASUREMENT_ID= optional
+NEXT_PUBLIC_GOOGLE_API_KEY= create an api key in GCP
+NEXT_PUBLIC_URL= http://localhost:3000 for local development
+SUPER_ADMIN_API_KEY= anything
+TEST_DATABASE_URL= create a second database in postgres
\ No newline at end of file
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 9785090e..3461d680 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -20,6 +20,25 @@
"outFiles": ["${workspaceFolder}/**/*.js"],
"console": "integratedTerminal"
},
+ {
+ "name": "Next.js: Full Stack Debug",
+ "type": "node",
+ "request": "launch",
+ "cwd": "${workspaceFolder}/apps/nextjs",
+ "runtimeExecutable": "pnpm",
+ "runtimeArgs": ["dev"],
+ "serverReadyAction": {
+ "pattern": "started server on .+, url: (https?://.+)",
+ "uriFormat": "%s",
+ "action": "debugWithChrome"
+ },
+ "skipFiles": ["/**"],
+ "outFiles": ["${workspaceFolder}/**/*.js"],
+ "console": "integratedTerminal",
+ "env": {
+ "NODE_OPTIONS": "--inspect"
+ }
+ },
{
"type": "node",
"request": "launch",
diff --git a/README.md b/README.md
index 3a00999d..5b89f896 100644
--- a/README.md
+++ b/README.md
@@ -180,3 +180,12 @@ Environment variables are application-specific in this monorepo. The `.env` file
- For dev migrations use the `dev_generic` user
- For staging migrations use the `dev_generic` user
- For production migrations use the `f3slackbot` user
+
+# WIP
+
+- ensure we can't submit with no time and no events for new events
+- add tests for approvals in the admin portal
+- test image issues
+- better text of where we're moving aos and regions
+- decide which things you can change region and and which you can't
+- TODO: Think through AO and location appearance
diff --git a/apps/map/e2e/auth/admin-portal.spec.ts b/apps/map/e2e/auth/admin-portal.spec.ts
new file mode 100644
index 00000000..1b97550e
--- /dev/null
+++ b/apps/map/e2e/auth/admin-portal.spec.ts
@@ -0,0 +1,357 @@
+import { expect, test } from "@playwright/test";
+
+import {
+ addNewItemAndVerify,
+ cleanupTestData,
+ deleteItemAndVerify,
+ editItemAndVerify,
+ fillTimeInput,
+ goToAdminPortal,
+ goToSection,
+} from "../helpers";
+
+const prefix = "SUPER TEST";
+
+// To run this file:
+// 1. pnpm -F nextjs test:e2e:ui --grep="admin"
+// 2. Commend out beforeAll cleanupTestData (~line 15) if running directly
+test.describe("Admin Portal", () => {
+ test.beforeAll(async ({ browser }) => {
+ // Create a new page for cleanup only
+ const context = await browser.newContext();
+ const page = await context.newPage();
+
+ // Clean up any leftover test data before starting tests
+ await cleanupTestData({ page, prefix });
+
+ // Close the context when done
+ await context.close();
+ });
+
+ test.beforeEach(async ({ page }) => {
+ // await setupAdminTestEnvironment(page);
+ await goToAdminPortal(page);
+ });
+
+ test("go to Sectors and add a new Sector", async ({ page }) => {
+ await expect(page).toHaveURL(/\/admin/);
+ await goToSection(page, "Sectors");
+ await addNewItemAndVerify({
+ page,
+ section: "Sector",
+ extraSteps: async () => {
+ await page.getByRole("combobox").filter({ hasText: /^$/ }).click();
+ await page.getByRole("option", { name: "F3 Nation" }).click();
+ },
+ prefix,
+ });
+ });
+
+ test("edit the new Sector", async ({ page }) => {
+ await goToSection(page, "Sectors");
+ await editItemAndVerify({
+ page,
+ name: "SECTOR",
+ order: 2,
+ prefix,
+ });
+ });
+
+ test("go to Areas and add a new Area", async ({ page }) => {
+ await expect(page).toHaveURL(/\/admin/);
+ await goToSection(page, "Areas");
+ await addNewItemAndVerify({
+ page,
+ section: "Area",
+ extraSteps: async () => {
+ await page.getByRole("combobox").filter({ hasText: /^$/ }).click();
+ await page.getByRole("option", { name: "SUPER TEST SECTOR" }).click();
+ },
+ prefix,
+ });
+ });
+
+ test("edit the new Area", async ({ page }) => {
+ await goToSection(page, "Areas");
+ await editItemAndVerify({
+ page,
+ name: "AREA",
+ order: 2,
+ prefix,
+ });
+ });
+
+ test("go to Regions and add a new Region", async ({ page }) => {
+ await expect(page).toHaveURL(/\/admin/);
+ await goToSection(page, "Regions");
+ await addNewItemAndVerify({
+ page,
+ section: "Region",
+ extraSteps: async () => {
+ await page.getByRole("combobox").filter({ hasText: /^$/ }).click();
+ await page.getByRole("option", { name: `${prefix} AREA 2` }).click();
+ },
+ prefix,
+ });
+ });
+
+ test("edit the new Region", async ({ page }) => {
+ await goToSection(page, "Regions");
+ await editItemAndVerify({
+ page,
+ name: "REGION",
+ order: 2,
+ prefix,
+ });
+ });
+
+ test("go to AOs and add a new AO", async ({ page }) => {
+ await expect(page).toHaveURL(/\/admin/);
+ await goToSection(page, "AOs");
+ await addNewItemAndVerify({
+ page,
+ section: "AO",
+ prefix,
+ extraSteps: async () => {
+ await page.getByRole("combobox").filter({ hasText: /^$/ }).click();
+ await page.getByPlaceholder("Select a region").fill("super");
+ await page
+ .getByRole("option", { name: `${prefix} REGION 2` })
+ .first()
+ .click();
+ },
+ });
+ });
+
+ test("edit the new AO", async ({ page }) => {
+ await goToSection(page, "AOs");
+ await editItemAndVerify({
+ page,
+ name: "AO",
+ order: 2,
+ prefix,
+ });
+ });
+
+ test("go to Event Types and add a new Event Type", async ({ page }) => {
+ await goToSection(page, "Event Types");
+ await addNewItemAndVerify({
+ page,
+ section: "Event Type",
+ prefix,
+ extraSteps: async () => {
+ await page
+ .locator('textarea[name="description"]')
+ .fill("Test event type description");
+ await page.getByRole("combobox", { name: "Event Category" }).click();
+ await page.getByRole("option", { name: "1st F" }).click();
+ await page
+ .getByRole("combobox")
+ .filter({ hasText: "Select a region" })
+ .click();
+ await page.getByPlaceholder("Select a region").fill("SUPER");
+ await page
+ .getByRole("option", { name: "SUPER TEST REGION 2" })
+ .first()
+ .click();
+ },
+ });
+ });
+
+ test("edit the new Event Type", async ({ page }) => {
+ await goToSection(page, "Event Types");
+ await editItemAndVerify({
+ page,
+ name: "EVENT TYPE",
+ order: 2,
+ prefix,
+ });
+ });
+
+ test("go to Locations and add a new Location", async ({ page }) => {
+ await goToSection(page, "Locations");
+ await addNewItemAndVerify({
+ page,
+ section: "Location",
+ prefix,
+ extraSteps: async () => {
+ await page
+ .getByRole("textbox", { name: "Name" })
+ .fill("SUPER TEST LOCATION");
+ await page.waitForTimeout(250);
+ await page
+ .getByRole("combobox")
+ .filter({ hasText: "Select a region" })
+ .click();
+ await page.getByPlaceholder("Select a region").fill(prefix);
+ await page
+ .getByRole("option", { name: `${prefix} REGION 2` })
+ .first()
+ .click();
+ },
+ });
+ });
+
+ test("edit the new Location", async ({ page }) => {
+ await goToSection(page, "Locations");
+ await editItemAndVerify({
+ page,
+ name: "LOCATION",
+ order: 2,
+ prefix,
+ });
+ });
+
+ test("go to Events and add a new Event", async ({ page }) => {
+ await goToSection(page, "Events");
+ await addNewItemAndVerify({
+ page,
+ section: "Event",
+ prefix,
+ extraSteps: async () => {
+ //Select the region
+ await page
+ .getByRole("combobox")
+ .filter({ hasText: "Select a region" })
+ .click();
+ await page.getByPlaceholder("Select a region").fill("SUPER");
+ await page
+ .getByRole("option", { name: `${prefix} REGION 2` })
+ .first()
+ .click();
+
+ // Select the AO
+ await page
+ .getByRole("combobox")
+ .filter({ hasText: "Select an AO" })
+ .click();
+ await page
+ .getByRole("option", { name: `${prefix} AO 2` })
+ .first()
+ .click();
+
+ // See if location already is selected
+ try {
+ await expect(
+ page
+ .getByRole("combobox")
+ .filter({ hasText: `${prefix} LOCATION 2` }),
+ ).toBeVisible();
+ } catch (error) {
+ // Select a location
+ await page
+ .getByRole("combobox")
+ .filter({ hasText: "Select a location" })
+ .click();
+ await page.getByPlaceholder("Select a location").fill(prefix);
+ await page
+ .getByRole("option", { name: `${prefix} LOCATION 2` })
+ .first()
+ .click();
+ }
+
+ // Day of the week
+ await page.getByRole("combobox", { name: "Day of Week" }).click();
+ await page.getByRole("option", { name: "Wednesday" }).click();
+
+ // Select Event Type
+ await page
+ .getByRole("combobox")
+ .filter({ hasText: "Select event types" })
+ .click();
+ await page.getByPlaceholder("Select event types").fill(prefix);
+ await page
+ .getByRole("option", { name: `${prefix} EVENT TYPE 2` })
+ .first()
+ .click();
+
+ await page
+ .getByRole("combobox")
+ .filter({ hasText: `${prefix} EVENT TYPE 2` })
+ .click();
+
+ // Set date and time
+ const tomorrow = new Date();
+ tomorrow.setDate(tomorrow.getDate() + 1);
+ const dateString = tomorrow.toISOString().split("T")[0] ?? "";
+
+ await page.getByLabel("Start Date").fill(dateString);
+ await fillTimeInput(page, "startTime", "0600A");
+ await page.keyboard.press("Tab");
+ await page.keyboard.press("Tab");
+ await fillTimeInput(page, "endTime", "0700A");
+ },
+ });
+ });
+
+ test("edit the new Event", async ({ page }) => {
+ await goToSection(page, "Events");
+ await editItemAndVerify({
+ page,
+ name: "EVENT",
+ order: 2,
+ prefix,
+ });
+ });
+
+ // Delete all test items in reverse order
+ test("delete the Event", async ({ page }) => {
+ await deleteItemAndVerify({
+ page,
+ section: "Events",
+ name: `${prefix} EVENT 2`,
+ });
+ });
+
+ test("delete the Event Type", async ({ page }) => {
+ await deleteItemAndVerify({
+ page,
+ section: "Event Types",
+ name: `${prefix} EVENT TYPE 2`,
+ });
+ });
+
+ test("delete the new Location", async ({ page }) => {
+ await deleteItemAndVerify({
+ page,
+ section: "Locations",
+ name: `${prefix} LOCATION 2`,
+ });
+ });
+
+ test("delete the new AO", async ({ page }) => {
+ await deleteItemAndVerify({
+ page,
+ section: "AOs",
+ name: `${prefix} AO 2`,
+ });
+ });
+
+ test("delete the new Region", async ({ page }) => {
+ await deleteItemAndVerify({
+ page,
+ section: "Regions",
+ name: `${prefix} REGION 2`,
+ });
+ });
+
+ test("delete the new Area", async ({ page }) => {
+ await deleteItemAndVerify({
+ page,
+ section: "Areas",
+ name: `${prefix} AREA 2`,
+ });
+ });
+
+ test("delete the new Sector", async ({ page }) => {
+ await deleteItemAndVerify({
+ page,
+ section: "Sectors",
+ name: `${prefix} SECTOR 2`,
+ });
+ });
+
+ test("clean up test data", async ({ page }) => {
+ await cleanupTestData({ page, prefix });
+ });
+});
diff --git a/apps/map/e2e/helpers.ts b/apps/map/e2e/helpers.ts
new file mode 100644
index 00000000..af7fd2a8
--- /dev/null
+++ b/apps/map/e2e/helpers.ts
@@ -0,0 +1,264 @@
+import type { BrowserContext, Locator, Page } from "@playwright/test";
+import { expect } from "@playwright/test";
+
+export const testLogger = (context: string) => ({
+ log: (action: string, data?: Record) => {
+ console.log(context, {
+ action,
+ ...data,
+ });
+ },
+});
+
+export const waitForMap = async (page: Page, timeout = 10000) => {
+ const logger = testLogger("waitForMap");
+ logger.log("Waiting for map to load");
+
+ try {
+ await page.waitForSelector('[aria-label="Map"]', {
+ timeout,
+ state: "visible",
+ });
+ logger.log("Map loaded successfully");
+ } catch (error) {
+ logger.log("Map failed to load", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw error;
+ }
+};
+
+export const setupTestEnvironment = async (
+ page: Page,
+ context: BrowserContext,
+) => {
+ const logger = testLogger("setupTestEnvironment");
+ logger.log("Setting up test environment");
+
+ // Grant location permissions
+ await context.grantPermissions(["geolocation"]);
+};
+
+export const setupAdminTestEnvironment = async (page: Page) => {
+ await page.goto("/?lat=36.211104&lng=-81.660849&zoom=3");
+ await page.getByRole("button", { name: "Settings" }).click();
+ await page.getByRole("button", { name: "Sign in (Dev Mode)" }).click();
+};
+
+export const turnOnEditMode = async (page: Page) => {
+ await page.getByRole("button", { name: "Settings" }).click();
+ await page.getByRole("button", { name: "Edit" }).click();
+ await page.getByRole("button", { name: "Close" }).click();
+};
+
+export const goToAdminPortal = async (page: Page) => {
+ await page.goto("/admin");
+ // await page.getByRole("button", { name: "Settings" }).click();
+ // await page.getByRole("button", { name: "Admin Portal" }).click();
+};
+
+export const teardownTestEnvironment = async (context: BrowserContext) => {
+ const logger = testLogger("teardownTestEnvironment");
+ logger.log("Tearing down test environment");
+
+ // Stop tracing and save it
+ await context.tracing.stop({ path: "test-results/trace.zip" });
+};
+
+export const setStore = async (
+ page: Page,
+ store: "map-store",
+ value: Record,
+) => {
+ await page.addInitScript(`
+ window.localStorage.setItem('${store}', JSON.stringify(${JSON.stringify(value)}));
+ `);
+};
+
+/**
+ * Cleans up test data from the database.
+ * This function should be called at the beginning of test suites to ensure a clean state.
+ */
+export const cleanupTestData = async (params: {
+ page: Page;
+ prefix: "SUPER TEST" | "MAP TEST";
+}) => {
+ const { page, prefix } = params;
+ const logger = testLogger("cleanupTestData");
+ logger.log("Cleaning up test data");
+
+ // First navigate to admin portal (if not already there)
+ await goToAdminPortal(page);
+
+ // Delete any test items that might exist from previous test runs
+ await deleteTestItems(page, "Events", prefix);
+ await deleteTestItems(page, "Event Types", prefix);
+ await deleteTestItems(page, "Locations", prefix);
+ await deleteTestItems(page, "AOs", prefix);
+ await deleteTestItems(page, "Regions", prefix);
+ await deleteTestItems(page, "Areas", prefix);
+ await deleteTestItems(page, "Sectors", prefix);
+
+ logger.log("Test data cleanup completed");
+};
+
+const waitOrMoveOn = async (locator: Locator, timeoutMs = 1000) => {
+ try {
+ await locator.first().waitFor({ timeout: timeoutMs, state: "attached" });
+ return await locator.all();
+ } catch (error) {
+ // Timeout occurred or element not found
+ return [];
+ }
+};
+
+/**
+ * Helper function to delete all items containing the search term
+ * from a specific section in the admin portal
+ */
+async function deleteTestItems(
+ page: Page,
+ section: string,
+ searchTerm: string,
+) {
+ const logger = testLogger(`deleteTestItems:${section}`);
+ logger.log("Deleting test items", { section, searchTerm });
+
+ // Navigate to the correct section
+ await page.getByRole("link", { name: section }).click();
+ await page.waitForTimeout(250);
+
+ // Search for test items
+ await page.getByRole("textbox", { name: "Search rows..." }).click();
+ await page.getByRole("textbox", { name: "Search rows..." }).fill(searchTerm);
+
+ // Get count of matching items
+ const items = await waitOrMoveOn(page.getByText(new RegExp(searchTerm, "i")));
+ logger.log(`Found ${items.length} items to delete`, { count: items.length });
+
+ // Delete all found items
+ for (let i = 0; i < items.length; i++) {
+ try {
+ // Items list changes after each deletion, so we need to search again
+ const currentItems = await page
+ .getByText(new RegExp(searchTerm, "i"))
+ .all();
+ if (currentItems.length === 0) break;
+
+ // Click the first item - currentItems[0] is checked above so it's safe to use
+ const firstItem = currentItems[0];
+ if (firstItem) {
+ await firstItem.click();
+
+ // Click delete button and confirm
+ await page.getByRole("button", { name: "Delete" }).click();
+ await page.getByRole("button", { name: "Delete" }).click();
+
+ // Wait for confirmation
+ await page.waitForTimeout(500);
+
+ logger.log(`Deleted item ${i + 1}`, {
+ remaining: currentItems.length - 1,
+ });
+ }
+ } catch (error) {
+ logger.log("Error deleting item", {
+ error: error instanceof Error ? error.message : String(error),
+ index: i,
+ });
+ break;
+ }
+ }
+}
+
+export const fillTimeInput = async (
+ page: Page,
+ inputId: string,
+ time: string,
+) => {
+ await page.locator(`#${inputId}`).click();
+ for (const char of time) {
+ await page.keyboard.press(char);
+ }
+};
+
+export const searchTableRowsForText = async (
+ page: Page,
+ text: string,
+ click?: boolean,
+) => {
+ await page.getByRole("textbox", { name: "Search rows..." }).click();
+ await page.getByRole("textbox", { name: "Search rows..." }).fill(text);
+ await expect(
+ page.locator("tbody tr").first().locator("td").first(),
+ ).toHaveText(text);
+ if (click) {
+ await page.getByText(text).click();
+ }
+};
+
+export const verifyTableRowDoesNotExist = async (page: Page, text: string) => {
+ await page.getByRole("textbox", { name: "Search rows..." }).click();
+ await page.getByRole("textbox", { name: "Search rows..." }).fill(text);
+ await expect(
+ page.locator("tbody tr").first().locator("td").first(),
+ ).not.toHaveText(text);
+};
+
+export const goToSection = async (page: Page, section: string) => {
+ await page.getByRole("link", { name: section }).click();
+ await expect(page.getByRole("heading", { name: section })).toBeVisible();
+};
+
+export const addNewItemAndVerify = async (params: {
+ page: Page;
+ section: string;
+ extraSteps?: () => Promise;
+ prefix: "SUPER TEST" | "MAP TEST";
+}) => {
+ const { page, section, extraSteps, prefix } = params;
+ await page.getByRole("button", { name: `Add ${section}` }).click();
+ await expect(
+ page.getByRole("heading", { name: `Add ${section}` }),
+ ).toBeVisible();
+ await page
+ .getByRole("textbox", { name: "Name" })
+ .fill(`${prefix} ${section.toUpperCase()}`);
+ await extraSteps?.();
+ await page.getByRole("button", { name: "Save Changes" }).click();
+ await searchTableRowsForText(page, `${prefix} ${section.toUpperCase()}`);
+};
+
+export const editItemAndVerify = async (params: {
+ page: Page;
+ name: string;
+ order: number;
+ prefix: "SUPER TEST" | "MAP TEST";
+ extraSteps?: () => Promise;
+}) => {
+ const { page, name, order, prefix, extraSteps } = params;
+ const oldName = `${prefix} ${name}`;
+ const newName = `${prefix} ${name} ${order}`;
+ await searchTableRowsForText(page, oldName, true);
+ await expect(page.getByRole("textbox", { name: "Name" })).toHaveValue(
+ oldName,
+ );
+ await page.getByRole("textbox", { name: "Name" }).fill(newName);
+ await extraSteps?.();
+ await page.getByRole("button", { name: "Save Changes" }).click();
+ await searchTableRowsForText(page, newName);
+};
+
+export const deleteItemAndVerify = async (params: {
+ page: Page;
+ section: string;
+ name: string;
+}) => {
+ const { page, section, name } = params;
+ await goToSection(page, section);
+ await searchTableRowsForText(page, name, true);
+ await page.getByRole("button", { name: "Delete" }).click();
+ await page.waitForSelector("text=Are you sure");
+ await page.getByRole("button", { name: "Delete" }).click();
+ await verifyTableRowDoesNotExist(page, name);
+};
diff --git a/apps/map/e2e/no-auth/edit.spec.ts b/apps/map/e2e/no-auth/edit.spec.ts
new file mode 100644
index 00000000..279f2f3b
--- /dev/null
+++ b/apps/map/e2e/no-auth/edit.spec.ts
@@ -0,0 +1,475 @@
+import path from "path";
+import { expect, test } from "@playwright/test";
+
+import { SIDEBAR_WIDTH } from "@acme/shared/app/constants";
+// TODO: Remove all wait for timeouts
+
+import { TestId } from "@acme/shared/common/enums";
+
+import { turnOnEditMode } from "../helpers";
+
+const prefix = "EDIT FLOW";
+
+/**
+ * This test covers all map actions for admin: create, move, edit, add, and move event/AO.
+ */
+test.describe("Admin Map Actions", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/?lat=31.659308&lng=-132.955330&zoom=8");
+ await turnOnEditMode(page);
+ await page.waitForSelector('[aria-label="Map"]', { timeout: 30000 });
+ });
+
+ test("New location, AO, & event", async ({ page }) => {
+ const mapContainer = await page.$('[aria-label="Map"]');
+ const { width, height } = (await mapContainer?.boundingBox()) ?? {
+ width: 0,
+ height: 0,
+ };
+ await page.mouse.click(
+ (width - SIDEBAR_WIDTH) / 2 + SIDEBAR_WIDTH,
+ height / 2,
+ );
+ await expect(page.getByTestId(TestId.UPDATE_PANE_MARKER)).toBeVisible({
+ timeout: 2000,
+ });
+ await page
+ .getByRole("button", { name: "New location, AO, & event", exact: true })
+ .click();
+ await expect(page.getByText("New Location, AO, & Event")).toBeVisible({
+ timeout: 2000,
+ });
+ await page.getByRole("combobox").first().click();
+ await expect(
+ page
+ .getByRole("dialog", { name: "New Location, AO & Event" })
+ .getByText("Boone"),
+ ).toBeVisible({ timeout: 2000 });
+ await page.keyboard.type("Boone");
+ await expect(page.getByRole("option", { name: "Boone" })).toBeVisible({
+ timeout: 2000,
+ });
+ await page.getByRole("option", { name: "Boone" }).first().click();
+ await page.locator('input[name="locationAddress"]').fill("100 Test St");
+ await page.locator('input[name="locationCity"]').fill("Testville");
+ await page.locator('input[name="locationState"]').fill("NC");
+ await page.locator('input[name="locationZip"]').fill("11111");
+ await page.locator("#locationCountry").click();
+ await page.getByRole("option", { name: "United States" }).click();
+
+ // Get the existing lat and lng values from the inputs
+ const latInput = page.locator('input[name="locationLat"]');
+ const lngInput = page.locator('input[name="locationLng"]');
+ const latValueStr = await latInput.inputValue();
+ const lngValueStr = await lngInput.inputValue();
+ const latValue = parseFloat(latValueStr);
+ const lngValue = parseFloat(lngValueStr);
+ const newLat = (isNaN(latValue) ? 0 : latValue) + 2;
+ const newLng = (isNaN(lngValue) ? 0 : lngValue) + 2;
+ await latInput.fill(newLat.toString());
+ await lngInput.fill(newLng.toString());
+
+ await page
+ .locator('textarea[name="locationDescription"]')
+ .fill("First test location");
+ await page.getByText("AO Details:").scrollIntoViewIfNeeded();
+ await page.locator('input[name="aoName"]').fill(`${prefix} AO1`);
+ await page.locator('input[name="aoWebsite"]').fill("https://ao1.com");
+ const filePath = path.resolve(process.cwd(), "e2e/tests/image.png");
+ await page.locator('input[type="file"]').setInputFiles(filePath);
+ await page.getByText("Event Details:").scrollIntoViewIfNeeded();
+ await page.locator('input[name="eventName"]').fill(`${prefix} EVENT1`);
+ await page.locator("#eventDayOfWeek").click();
+ await page.getByRole("option", { name: "Monday" }).click();
+ await page.locator("#eventStartTime").click();
+ await page.keyboard.type("0600A");
+ await page.locator("#eventEndTime").click();
+ await page.keyboard.type("0700A");
+ await page.getByRole("button", { name: "Select event types" }).click();
+ await page.getByRole("option").first().click();
+ await page
+ .locator('textarea[name="eventDescription"]')
+ .fill("First event description");
+ await page.getByText("Contact Information:").scrollIntoViewIfNeeded();
+ await page.locator('input[name="submittedBy"]').fill("admin1@example.com");
+ await page.getByRole("button", { name: "Save Changes" }).click();
+ await page.waitForTimeout(2000);
+ // Ensure the update modal closes
+ await expect(page.getByRole("dialog")).not.toBeVisible({ timeout: 5000 });
+ });
+
+ test("Move existing AO here", async ({ page }) => {
+ const mapContainer = await page.$('[aria-label="Map"]');
+ const { width, height } = (await mapContainer?.boundingBox()) ?? {
+ width: 0,
+ height: 0,
+ };
+ await page.mouse.click(width / 2 + 50, height / 2 + 50);
+ await expect(page.getByTestId(TestId.UPDATE_PANE_MARKER)).toBeVisible({
+ timeout: 2000,
+ });
+ await page
+ .getByRole("button", { name: "Move existing AO here", exact: true })
+ .click();
+ await page.waitForTimeout(500);
+ await page.getByRole("combobox").first().click();
+ await page.keyboard.type("Boone");
+ await page.waitForTimeout(500);
+ await page.getByRole("option", { name: "Boone" }).first().click();
+ await page.getByRole("combobox").nth(1).click();
+ await page.keyboard.type(`${prefix} AO1`);
+ await page.waitForTimeout(500);
+ await page
+ .getByRole("option", { name: `${prefix} AO1` })
+ .first()
+ .click();
+ await page.locator('input[name="locationAddress"]').fill("200 Test St");
+ await page.locator('input[name="locationCity"]').fill("Moved City");
+ await page.locator('input[name="locationState"]').fill("NC");
+ await page.locator('input[name="locationZip"]').fill("22222");
+ await page.locator("#locationCountry").click();
+ await page.getByRole("option", { name: "United States" }).click();
+ await page
+ .locator('textarea[name="locationDescription"]')
+ .fill("Moved AO location");
+
+ await page.getByRole("button", { name: "Save Changes" }).click();
+ await page.waitForTimeout(2000);
+ // Ensure the update modal closes
+ await expect(page.getByRole("dialog")).not.toBeVisible({ timeout: 5000 });
+ });
+
+ test("Edit AO details", async ({ page }) => {
+ // Open AO panel
+ const aoButton = page
+ .getByRole("button", { name: `${prefix} AO1` })
+ .first();
+ await expect(aoButton).toBeVisible({ timeout: 10000 });
+ await aoButton.click();
+ await page.waitForTimeout(1000);
+ // Open edit dropdown
+ const panel = page.getByTestId(TestId.PANEL);
+ await expect(panel).toBeVisible({ timeout: 1000 });
+ // Click Edit AO details
+ await page.getByRole("button", { name: "Edit AO:" }).first().click();
+ await page
+ .getByRole("menuitem", { name: "Edit AO details" })
+ .first()
+ .click();
+ await page.locator('input[name="aoName"]').fill(`${prefix} AO1 Updated`);
+ await page
+ .locator('input[name="aoWebsite"]')
+ .fill("https://ao1-updated.com");
+ const filePath = path.resolve(process.cwd(), "e2e/tests/image.png");
+ await page.locator('input[type="file"]').setInputFiles(filePath);
+ await page.locator('input[name="locationAddress"]').fill("201 Test St");
+ await page.locator('input[name="locationCity"]').fill("Updated City");
+ await page.locator('input[name="locationState"]').fill("NC");
+ await page.locator('input[name="locationZip"]').fill("33333");
+ await page.locator("#locationCountry").click();
+ await page.getByRole("option", { name: "Mexico" }).click();
+ await page
+ .locator('textarea[name="locationDescription"]')
+ .fill("Updated AO location");
+ await page.getByRole("button", { name: "Save Changes" }).click();
+ await page.waitForTimeout(2000);
+ // Ensure the update modal closes
+ await expect(page.getByRole("dialog")).not.toBeVisible({ timeout: 5000 });
+ });
+
+ test("Move to different region", async ({ page }) => {
+ // Open AO panel
+ const aoButton = page
+ .getByRole("button", { name: `${prefix} AO1 Updated` })
+ .first();
+ await expect(aoButton).toBeVisible({ timeout: 10000 });
+ await aoButton.click();
+ await page.waitForTimeout(1000);
+ // Open edit dropdown
+ const panel = page.getByTestId(TestId.PANEL);
+ await expect(panel).toBeVisible({ timeout: 1000 });
+ await page.getByRole("button", { name: "Edit AO:" }).first().click();
+ // Click Move to different region
+ await page
+ .getByRole("menuitem", { name: "Move to different region" })
+ .click();
+ await page.getByRole("combobox").first().click();
+ await page.keyboard.type("Charlotte");
+ await page.waitForTimeout(500);
+ await page.getByRole("option", { name: "Charlotte" }).first().click();
+ await page.getByRole("button", { name: "Save Changes" }).click();
+ await page.waitForTimeout(2000);
+ // Ensure the update modal closes
+ await expect(page.getByRole("dialog")).not.toBeVisible({ timeout: 5000 });
+ });
+
+ test("Edit workout details", async ({ page }) => {
+ // Open AO panel and select event
+ const aoButton = page
+ .getByRole("button", { name: `${prefix} AO1 Updated` })
+ .first();
+ await expect(aoButton).toBeVisible({ timeout: 10000 });
+ await aoButton.click();
+ await page.waitForTimeout(1000);
+ const panel = page.getByTestId(TestId.PANEL);
+ await expect(panel).toBeVisible({ timeout: 1000 });
+
+ const eventButton = page
+ .getByRole("button", { name: `${prefix} EVENT1` })
+ .first();
+ await expect(eventButton).toBeVisible({ timeout: 10000 });
+ await eventButton.click();
+ await page.waitForTimeout(1000);
+ // Open edit dropdown
+ // Click Edit workout details
+ await page.getByRole("menuitem", { name: "Edit workout details" }).click();
+ await page
+ .locator('input[name="eventName"]')
+ .fill(`${prefix} EVENT1 Updated`);
+ await page.locator("#eventDayOfWeek").click();
+ await page.getByRole("option", { name: "Friday" }).click();
+ await page.locator("#eventStartTime").click();
+ await page.keyboard.type("0630A");
+ await page.locator("#eventEndTime").click();
+ await page.keyboard.type("0730A");
+ await page
+ .locator(
+ 'div:has-text("Event Types") ~ div button[aria-haspopup="dialog"]',
+ )
+ .click();
+ await page.getByRole("option").nth(1).click();
+ await page
+ .locator('textarea[name="eventDescription"]')
+ .fill("Updated event description");
+ await page.getByRole("button", { name: "Save Changes" }).click();
+ await page.waitForTimeout(2000);
+ // Ensure the update modal closes
+ await expect(page.getByRole("dialog")).not.toBeVisible({ timeout: 5000 });
+ });
+
+ test("New location, AO, & event (2)", async ({ page }) => {
+ const mapContainer = await page.$('[aria-label="Map"]');
+ const { width, height } = (await mapContainer?.boundingBox()) ?? {
+ width: 0,
+ height: 0,
+ };
+ await page.mouse.click(width / 2 - 50, height / 2 - 50);
+ await expect(page.getByTestId(TestId.UPDATE_PANE_MARKER)).toBeVisible({
+ timeout: 5000,
+ });
+ await page
+ .getByRole("button", { name: "New location, AO, & event", exact: true })
+ .click();
+ await page.waitForTimeout(500);
+ await page.getByRole("combobox").first().click();
+ await page.keyboard.type("Boone");
+ await page.waitForTimeout(500);
+ await page.getByRole("option", { name: "Boone" }).first().click();
+ await page.locator('input[name="locationAddress"]').fill("300 Test St");
+ await page.locator('input[name="locationCity"]').fill("Testopolis");
+ await page.locator('input[name="locationState"]').fill("NC");
+ await page.locator('input[name="locationZip"]').fill("44444");
+ await page
+ .locator('textarea[name="locationDescription"]')
+ .fill("Second test location");
+ await page.getByText("AO Details:").scrollIntoViewIfNeeded();
+ await page.locator('input[name="aoName"]').fill(`${prefix} AO2`);
+ await page.locator('input[name="aoWebsite"]').fill("https://ao2.com");
+ const filePath = path.resolve(process.cwd(), "e2e/tests/image.png");
+ await page.locator('input[type="file"]').setInputFiles(filePath);
+ await page.getByText("Event Details:").scrollIntoViewIfNeeded();
+ await page.locator('input[name="eventName"]').fill(`${prefix} EVENT2`);
+ await page.locator("#eventDayOfWeek").click();
+ await page.getByRole("option", { name: "Tuesday" }).click();
+ await page.locator("#eventStartTime").click();
+ await page.keyboard.type("0600A");
+ await page.locator("#eventEndTime").click();
+ await page.keyboard.type("0700A");
+ await page.getByRole("button", { name: "Select event types" }).click();
+ await page.getByRole("option").first().click();
+ await page
+ .locator('textarea[name="eventDescription"]')
+ .fill("Second event description");
+ await page.getByText("Contact Information:").scrollIntoViewIfNeeded();
+ await page.locator('input[name="submittedBy"]').fill("admin2@example.com");
+ await page.getByRole("button", { name: "Save Changes" }).click();
+ await page.waitForTimeout(2000);
+ // Ensure the update modal closes
+ await expect(page.getByRole("dialog")).not.toBeVisible({ timeout: 5000 });
+ });
+
+ test("Add workout", async ({ page }) => {
+ // Open AO2 panel
+ const aoButton = page
+ .getByRole("button", { name: `${prefix} AO2` })
+ .first();
+ await expect(aoButton).toBeVisible({ timeout: 10000 });
+ await aoButton.click();
+ await page.waitForTimeout(1000);
+ // Click Add Workout to AO
+ await page.getByRole("button", { name: "Add Workout to AO" }).click();
+ await page.locator('input[name="eventName"]').fill(`${prefix} EVENT3`);
+ await page.locator("#eventDayOfWeek").click();
+ await page.getByRole("option", { name: "Wednesday" }).click();
+ await page.locator("#eventStartTime").click();
+ await page.keyboard.type("0600A");
+ await page.locator("#eventEndTime").click();
+ await page.keyboard.type("0700A");
+ await page.getByRole("button", { name: "Select event types" }).click();
+ await page.getByRole("option").first().click();
+ await page
+ .locator('textarea[name="eventDescription"]')
+ .fill("Third event description");
+ await page.getByRole("button", { name: "Save Changes" }).click();
+ await page.waitForTimeout(2000);
+ // Ensure the update modal closes
+ await expect(page.getByRole("dialog")).not.toBeVisible({ timeout: 5000 });
+ });
+
+ test("Move to different AO", async ({ page }) => {
+ // Open AO2 panel and select event
+ const aoButton = page
+ .getByRole("button", { name: `${prefix} AO2` })
+ .first();
+ await expect(aoButton).toBeVisible({ timeout: 10000 });
+ await aoButton.click();
+ await page.waitForTimeout(1000);
+ const eventButton = page
+ // .getByRole("button", { name: `${prefix} EVENT3` })
+ .getByText("Wednesday 6AM - 7AM (60min)")
+ .first();
+ await expect(eventButton).toBeVisible({ timeout: 10000 });
+ await eventButton.click();
+
+ const editButton = page
+ .getByRole("button", { name: `Edit Workout: ${prefix} EVENT3` })
+ .first();
+ await expect(editButton).toBeVisible({ timeout: 10000 });
+ await editButton.click();
+
+ // Open edit dropdown
+ const panel = page.getByTestId(TestId.PANEL);
+ await expect(panel).toBeVisible({ timeout: 1000 });
+
+ // Click Move to different AO
+ await page.getByRole("menuitem", { name: "Move to different AO" }).click();
+ await page.getByRole("combobox").first().click();
+ await page.getByPlaceholder("Select Region").click();
+ await page.getByRole("option", { name: "Boone" }).first().click();
+ await page.getByRole("combobox").nth(1).click();
+ await page.getByPlaceholder("Select AO").click();
+ await page.keyboard.type(`${prefix} AO1 Updated`);
+ await page.waitForTimeout(500);
+ await page
+ .getByRole("option", { name: `${prefix} AO1 Updated (Boone)` })
+ .first()
+ .click();
+ await page.getByRole("button", { name: "Save Changes" }).click();
+ await page.waitForTimeout(2000);
+ // Ensure the update modal closes
+ await expect(page.getByRole("dialog")).not.toBeVisible({ timeout: 5000 });
+ });
+
+ test("Move existing event here", async ({ page }) => {
+ const mapContainer = await page.$('[aria-label="Map"]');
+ const { width, height } = (await mapContainer?.boundingBox()) ?? {
+ width: 0,
+ height: 0,
+ };
+ await page.mouse.click(width / 2 + 100, height / 2 + 100);
+ await expect(page.getByTestId(TestId.UPDATE_PANE_MARKER)).toBeVisible({
+ timeout: 5000,
+ });
+ await page
+ .getByRole("button", { name: "Move existing event here", exact: true })
+ .click();
+ await page.waitForTimeout(500);
+ await page.getByRole("combobox").first().click();
+ await page.keyboard.type("Boone");
+ await page.waitForTimeout(500);
+ await page.getByRole("option", { name: "Boone" }).first().click();
+ await page.getByRole("combobox").nth(1).click();
+ await page.keyboard.type(`${prefix} AO2 (Boone)`);
+ await page.waitForTimeout(500);
+ await page
+ .getByRole("option", { name: `${prefix} AO2 (Boone)` })
+ .first()
+ .click();
+ await page.getByRole("combobox").nth(2).click();
+ await page.keyboard.type(`${prefix} EVENT2`);
+ await page.waitForTimeout(500);
+ await page
+ .getByRole("option", { name: `${prefix} EVENT2` })
+ .first()
+ .click();
+ await page.locator('input[name="locationAddress"]').fill("400 Test St");
+ await page.locator('input[name="locationCity"]').fill("EventMoved City");
+ await page.locator('input[name="locationState"]').fill("NC");
+ await page.locator('input[name="locationZip"]').fill("55555");
+ await page
+ .locator('textarea[name="locationDescription"]')
+ .fill("Event moved location");
+ await page.getByRole("button", { name: "Save Changes" }).click();
+ await page.waitForTimeout(2000);
+ // Ensure the update modal closes
+ await expect(page.getByRole("dialog")).not.toBeVisible({ timeout: 5000 });
+ });
+
+ test("CLEANUP: delete created AOs", async ({ page }) => {
+ const maxAttempts = 10;
+ let attempt = 0;
+ while (attempt < maxAttempts) {
+ await page.reload();
+ await page.waitForTimeout(1000); // Give time for page to load
+ // Find all AO buttons with the prefix in their name
+ const nearbyLocations = page.getByTestId(TestId.NEARBY_LOCATIONS);
+ const aoButtons = await nearbyLocations.locator("button").all();
+ const aoButtonsWithPrefix = [];
+ for (const btn of aoButtons) {
+ const name = await btn.textContent();
+ if (name?.includes(prefix)) {
+ aoButtonsWithPrefix.push(btn);
+ }
+ }
+ if (aoButtonsWithPrefix.length === 0) {
+ break;
+ }
+ for (const aoButton of aoButtonsWithPrefix) {
+ if (await aoButton.isVisible({ timeout: 3000 }).catch(() => false)) {
+ await aoButton.click();
+ // Open edit dropdown
+ // wait until the edit button is visible
+ const editButton = page
+ .getByRole("button", { name: "Edit AO:" })
+ .first();
+ await editButton.waitFor({ state: "visible", timeout: 1000 });
+ await editButton.click();
+ // Click Delete AO
+ const deleteMenuItem = page
+ .getByRole("menuitem", { name: "Delete this AO", exact: true })
+ .first();
+ if (
+ await deleteMenuItem.isVisible({ timeout: 1000 }).catch(() => false)
+ ) {
+ await deleteMenuItem.click();
+ // Confirm deletion in modal/dialog
+ const confirmButton = page
+ .getByRole("button", { name: /Delete|Confirm/i })
+ .first();
+ if (
+ await confirmButton
+ .isVisible({ timeout: 1000 })
+ .catch(() => false)
+ ) {
+ await confirmButton.click();
+ await page.waitForTimeout(1000);
+ }
+ }
+ }
+ }
+ attempt++;
+ }
+ });
+});
+
+// TODO: test deleting a workout first. then delete an AO
diff --git a/apps/map/tests/inital-load.spec.ts b/apps/map/e2e/no-auth/initial-load.spec.ts
similarity index 97%
rename from apps/map/tests/inital-load.spec.ts
rename to apps/map/e2e/no-auth/initial-load.spec.ts
index be1fcefa..567e289d 100644
--- a/apps/map/tests/inital-load.spec.ts
+++ b/apps/map/e2e/no-auth/initial-load.spec.ts
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
import { TestId } from "@acme/shared/common/enums";
-import { setStore, setupTestEnvironment, waitForMap } from "./helpers";
+import { setStore, setupTestEnvironment, waitForMap } from "../helpers";
test.describe("Initial Load", () => {
test.beforeEach(async ({ page, context }) => {
diff --git a/apps/map/tests/manage-event-workflow.spec.ts b/apps/map/e2e/no-auth/manage-event-workflow.spec.ts
similarity index 77%
rename from apps/map/tests/manage-event-workflow.spec.ts
rename to apps/map/e2e/no-auth/manage-event-workflow.spec.ts
index ee20592d..cb9b4662 100644
--- a/apps/map/tests/manage-event-workflow.spec.ts
+++ b/apps/map/e2e/no-auth/manage-event-workflow.spec.ts
@@ -2,17 +2,34 @@ import { expect, test } from "@playwright/test";
import { TestId } from "@acme/shared/common/enums";
-import { setupAdminTestEnvironment } from "./helpers";
+import {
+ cleanupTestData,
+ setupAdminTestEnvironment,
+ turnOnEditMode,
+} from "../helpers";
+
+test.describe("Manage Workout Workflow", () => {
+ test.beforeAll(async ({ browser }) => {
+ // Create a new page for cleanup only
+ const context = await browser.newContext();
+ const page = await context.newPage();
+
+ // Clean up any leftover test data before starting tests
+ await cleanupTestData({ page, prefix: "MAP TEST" });
+
+ // Close the context when done
+ await context.close();
+ });
-test.describe("Manage Event Workflow", () => {
test.beforeEach(async ({ page }) => {
await setupAdminTestEnvironment(page);
+ await turnOnEditMode(page);
});
test("Find test AO if it exists and delete it", async ({ page }) => {
const mapSearchboxInput = page.getByTestId(TestId.MAP_SEARCHBOX_INPUT);
await mapSearchboxInput.click();
- await mapSearchboxInput.fill("Test event");
+ await mapSearchboxInput.fill("Test workout");
await page.waitForTimeout(2000);
const searchResults = page.getByTestId(
@@ -22,7 +39,7 @@ test.describe("Manage Event Workflow", () => {
const testAoButtonText = await testAoButton.textContent();
console.log("testAoButtonText", testAoButtonText);
if (
- !testAoButtonText?.toLowerCase().includes("test event") ||
+ !testAoButtonText?.toLowerCase().includes("test workout") ||
!testAoButtonText?.toLowerCase().includes("workout")
) {
console.log("Exiting: testAoButtonText", testAoButtonText);
@@ -31,14 +48,14 @@ test.describe("Manage Event Workflow", () => {
await testAoButton.click();
const selectedItem = page.getByTestId(TestId.SELECTED_ITEM_DESKTOP);
await expect(selectedItem).toBeVisible();
- await expect(selectedItem).toContainText("Test event", {
+ await expect(selectedItem).toContainText("Test workout", {
ignoreCase: true,
});
await selectedItem.click();
const panel = page.getByTestId(TestId.PANEL);
await expect(panel).toBeVisible();
- await expect(panel).toContainText("Test event", {
+ await expect(panel).toContainText("Test workout", {
ignoreCase: true,
});
@@ -97,24 +114,26 @@ test.describe("Manage Event Workflow", () => {
const mapSearchboxInput = page.getByTestId(TestId.MAP_SEARCHBOX_INPUT);
await mapSearchboxInput.click();
- await mapSearchboxInput.fill("test event");
+ await mapSearchboxInput.fill("test workout");
- await page.getByRole("button", { name: "item Test Event Workout" }).click();
+ await page
+ .getByRole("button", { name: "item Test Workout Workout" })
+ .click();
const selectedItem = page.getByTestId(TestId.SELECTED_ITEM_DESKTOP);
await expect(selectedItem).toBeVisible();
- await expect(selectedItem).toContainText("Test Event");
+ await expect(selectedItem).toContainText("Test Workout");
await selectedItem.click();
const panel = page.getByTestId(TestId.PANEL);
await expect(panel).toBeVisible();
- await expect(panel).toContainText("Test Event");
+ await expect(panel).toContainText("Test Workout");
const editButton = page
.getByRole("button", { name: "Edit Workout" })
.first();
await editButton.click();
await page.locator('input[name="eventName"]').click();
- await page.locator('input[name="eventName"]').fill("Test Event 1234");
+ await page.locator('input[name="eventName"]').fill("Test Workout 1234");
await page.waitForTimeout(500);
await page.getByRole("button", { name: "Save Changes" }).click();
await page.waitForTimeout(2000); // wait for the map to update
diff --git a/apps/map/e2e/no-auth/nearby-locations.spec.ts b/apps/map/e2e/no-auth/nearby-locations.spec.ts
new file mode 100644
index 00000000..08512c2e
--- /dev/null
+++ b/apps/map/e2e/no-auth/nearby-locations.spec.ts
@@ -0,0 +1,40 @@
+import { expect, test } from "@playwright/test";
+
+import { TestId } from "@acme/shared/common/enums";
+
+// Test: Search for 'TEST EVENT' in the search bar
+
+const prefix = "EDIT TEST";
+
+test("searches for TEST EVENT in the search bar", async ({ page }) => {
+ // Navigate to the site
+ await page.goto("http://localhost:3000");
+
+ // Click the search bar (try common selectors)
+
+ // Search and open the created item
+ const mapSearchboxInput = page.getByTestId(TestId.MAP_SEARCHBOX_INPUT);
+ await mapSearchboxInput.click();
+ await mapSearchboxInput.fill(prefix);
+ await page.waitForTimeout(2000);
+ const searchResult = page
+ .getByRole("button", {
+ name: `item ${prefix} Event Workout (F3 Boone)`,
+ exact: true,
+ })
+ .first();
+ await expect(searchResult).toBeVisible({ timeout: 10000 });
+ await searchResult.click();
+ await page.waitForTimeout(1000);
+
+ // await page.setViewportSize({ width: 1280, height: 720 });
+
+ const selectedItem = page.getByTestId(TestId.SELECTED_ITEM_DESKTOP);
+ await expect(selectedItem).toBeVisible({ timeout: 5000 });
+ await selectedItem.click();
+ await page.waitForTimeout(1000);
+ const panel = page.getByTestId(TestId.PANEL);
+ await expect(panel).toBeVisible({ timeout: 5000 });
+ await expect(panel).toContainText(`${prefix} AO`, { timeout: 5000 });
+ await expect(panel).toContainText(`${prefix} EVENT`, { timeout: 5000 });
+});
diff --git a/apps/map/e2e/no-auth/update-actions.spec.ts b/apps/map/e2e/no-auth/update-actions.spec.ts
new file mode 100644
index 00000000..b2c21fbe
--- /dev/null
+++ b/apps/map/e2e/no-auth/update-actions.spec.ts
@@ -0,0 +1,314 @@
+import path from "path";
+import { expect, test } from "@playwright/test";
+
+import { TestId } from "@acme/shared/common/enums";
+
+import { turnOnEditMode } from "../helpers";
+
+/**
+ * As an authenticated user, go through all the update actions in the map and verify that the updates are correct
+ *
+ * 1. Go to the map
+ * 2. turn on edit mode
+ * 3. click on the map and create a new location with an event for the Boone region
+ * 4. do the other new marker actions
+ * 5. Go to this new ao
+ * 6. do the update actions allowed there
+ */
+
+test.describe("Update Actions", () => {
+ test.beforeAll(async ({ browser }) => {
+ // Create a new page for cleanup only
+ const context = await browser.newContext();
+ const _page = await context.newPage();
+
+ // Clean up any leftover test data before starting tests
+ // Turn this off for now to improve test speed
+ // await cleanupTestData(page, "MAP TEST");
+
+ // Close the context when done
+ await context.close();
+ });
+
+ test.beforeEach(async ({ page }) => {
+ // We already have an auth state saved so we don't need to set up an auth environment
+ await page.goto("/");
+ await turnOnEditMode(page);
+ });
+
+ test("should create a new location with AO and event", async ({ page }) => {
+ console.log("Starting create and update test");
+ // Wait for the map to be fully loaded
+ await page.waitForSelector('[aria-label="Map"]', { timeout: 30000 });
+
+ // Get the map dimensions
+ const mapContainer = await page.$('[aria-label="Map"]');
+ const { width, height } = (await mapContainer?.boundingBox()) ?? {
+ width: 0,
+ height: 0,
+ };
+ console.log("Map dimensions", { width, height });
+
+ // Click on the map to create a new location
+ await page.mouse.click(width / 2, height / 2);
+
+ // Verify the update pane marker is visible
+ const updatePaneMarker = page.getByTestId(TestId.UPDATE_PANE_MARKER);
+ await expect(updatePaneMarker).toBeVisible({ timeout: 5000 });
+
+ // Click on the "New location, AO, & event" button
+ const newLocationButton = page.getByRole("button", {
+ name: "New location, AO, & event",
+ exact: true,
+ });
+ await expect(newLocationButton).toBeVisible({ timeout: 5000 });
+ await newLocationButton.click();
+ await page.waitForTimeout(1000);
+
+ // Fill in the form fields
+
+ // 1. Fill in Region Details
+ await page.getByRole("combobox").first().click();
+ await page.keyboard.type("Boone");
+ await page.waitForTimeout(500);
+ await page.getByRole("option", { name: "Boone" }).first().click();
+ await page.waitForTimeout(500);
+
+ // 2. Fill in Location Details
+ await page.locator('input[name="locationAddress"]').fill("123 Test Street");
+ await page.locator('input[name="locationCity"]').fill("Test City");
+ await page.locator('input[name="locationState"]').fill("NC");
+ await page.locator('input[name="locationZip"]').fill("12345");
+ await page
+ .locator('textarea[name="locationDescription"]')
+ .fill("This is a test location description");
+
+ // 3. Fill in Event Details
+ await page.locator('input[name="eventName"]').fill("MAP TEST Event");
+
+ // Select day of week
+ await page.getByText("Day of Week").scrollIntoViewIfNeeded();
+ await page.locator("#eventDayOfWeek").click();
+ await page.getByRole("option", { name: "Wednesday" }).click();
+
+ // Set times
+ await page.locator("#eventStartTime").click();
+ await page.keyboard.type("0600A");
+ await page.locator("#eventEndTime").click();
+ await page.keyboard.type("0700A");
+
+ // Select event types
+ await page.getByRole("button", { name: "Select event types" }).click();
+ await page.waitForTimeout(500);
+ const eventTypeOption = page.getByRole("option").first();
+ await eventTypeOption.click();
+
+ // Add event description
+ await page
+ .locator('textarea[name="eventDescription"]')
+ .fill("This is a test event description");
+
+ // 4. Fill in AO Details
+ await page.getByText("AO Details:").scrollIntoViewIfNeeded();
+ await page.locator('input[name="aoName"]').fill("MAP TEST AO");
+ await page.locator('input[name="aoWebsite"]').fill("https://example.com");
+ const fileChooserPromise = page.waitForEvent("filechooser");
+ await page.locator('input[name="aoLogo"]').click();
+ const fileChooser = await fileChooserPromise;
+ // Base is apps/nextjs
+ const logoPath = path.join(process.cwd(), "public/f3_logo.png");
+ console.log("logoPath", logoPath);
+ await fileChooser.setFiles("public/f3_logo.png");
+
+ // 5. Fill Contact Details
+ await page.locator('input[name="submittedBy"]').fill("test@example.com");
+
+ // Submit the form
+ await page.getByTestId(TestId.UPDATE_MODAL_SUBMIT_BUTTON).click();
+ await page.waitForTimeout(2000);
+
+ // Search for the newly created item
+ const mapSearchboxInput = page.getByTestId(TestId.MAP_SEARCHBOX_INPUT);
+ await mapSearchboxInput.click();
+ await mapSearchboxInput.fill("MAP TEST");
+ await page.waitForTimeout(2000);
+
+ // Click on the search result
+ const searchResult = page
+ .getByRole("button", { name: /MAP TEST/i })
+ .first();
+ await expect(searchResult).toBeVisible({ timeout: 10000 });
+ await searchResult.click();
+ await page.waitForTimeout(1000);
+
+ // Verify the selected item is visible
+ const selectedItem = page.getByTestId(TestId.SELECTED_ITEM_DESKTOP);
+ await expect(selectedItem).toBeVisible({ timeout: 5000 });
+ await expect(selectedItem).toContainText("MAP TEST", { timeout: 5000 });
+
+ // Click on the selected item to open the panel
+ await selectedItem.click();
+ await page.waitForTimeout(1000);
+
+ // Verify the panel is visible with our data
+ const panel = page.getByTestId(TestId.PANEL);
+ await expect(panel).toBeVisible({ timeout: 5000 });
+ await expect(panel).toContainText("MAP TEST AO", { timeout: 5000 });
+ await expect(panel).toContainText("MAP TEST Event", { timeout: 5000 });
+
+ // Edit the item
+ const editButton = page.getByRole("button", { name: "Edit" }).first();
+ await editButton.click();
+ await page.waitForTimeout(1000);
+
+ // Update the name
+ const eventNameInput = page.locator('input[name="eventName"]');
+ await eventNameInput.click();
+ await eventNameInput.fill("MAP TEST Event Updated");
+
+ // Save the changes
+ await page.getByRole("button", { name: "Save Changes" }).click();
+ await page.waitForTimeout(2000);
+
+ // Verify the changes were saved
+ await expect(panel).toContainText("MAP TEST Event Updated", {
+ timeout: 5000,
+ });
+
+ // Delete the item
+ const deleteButton = page.getByRole("button", { name: "Delete" }).first();
+ await deleteButton.click();
+ await page.waitForTimeout(1000);
+
+ await page.getByRole("button", { name: "Delete" }).click();
+ await page.waitForTimeout(2000);
+
+ // Verify it was deleted
+ await mapSearchboxInput.click();
+ await mapSearchboxInput.fill("MAP TEST Event Updated");
+ await page.waitForTimeout(2000);
+
+ try {
+ const count = await page
+ .getByRole("button", { name: /MAP TEST Event Updated/i })
+ .count();
+ if (count > 0) {
+ await expect(
+ page.getByRole("button", { name: /MAP TEST Event Updated/i }),
+ ).not.toBeVisible({ timeout: 5000 });
+ }
+ } catch (e) {
+ console.log("Button not found, which is expected if item was deleted");
+ }
+ });
+
+ test("should test move AO to new location", async ({ page }) => {
+ // Wait for the map to be fully loaded
+ await page.waitForSelector('[aria-label="Map"]', { timeout: 30000 });
+
+ // Get the map dimensions
+ const mapContainer = await page.$('[aria-label="Map"]');
+ const { width, height } = (await mapContainer?.boundingBox()) ?? {
+ width: 0,
+ height: 0,
+ };
+
+ // Click on a different spot on the map
+ await page.mouse.click(width / 2 + 100, height / 2 - 100);
+ await page.waitForTimeout(1000);
+
+ // Verify the update pane marker is visible
+ const updatePaneMarker = page.getByTestId(TestId.UPDATE_PANE_MARKER);
+ await expect(updatePaneMarker).toBeVisible({ timeout: 5000 });
+
+ // Click on the marker to open the update pane
+ await updatePaneMarker.click();
+ await page.waitForTimeout(1000);
+
+ // Click on "Move existing AO here" button
+ const moveAOButton = page.getByRole("button", {
+ name: "Move existing AO here",
+ exact: true,
+ });
+ await expect(moveAOButton).toBeVisible({ timeout: 5000 });
+ await moveAOButton.click();
+ await page.waitForTimeout(1000);
+
+ // Select region (just to test the UI, not actually submitting)
+ await page.getByRole("combobox").first().click();
+ await page.keyboard.type("Boone");
+ await page.waitForTimeout(500);
+ await page.getByRole("option", { name: "Boone" }).first().click();
+ await page.waitForTimeout(500);
+
+ // Try to select an AO
+ try {
+ await page.getByText("AO Selection:").scrollIntoViewIfNeeded();
+ const aoSelector = page.getByRole("combobox").nth(1);
+ if (await aoSelector.isVisible({ timeout: 2000 })) {
+ await aoSelector.click();
+ await page.keyboard.type("Yarak");
+ await page.waitForTimeout(1000);
+ }
+ } catch (e) {
+ console.log("Could not select AO");
+ }
+
+ // Fill location details
+ await page.locator('input[name="locationAddress"]').fill("456 Move Street");
+ await page.locator('input[name="locationCity"]').fill("Move City");
+
+ // Cancel the operation
+ await page.getByRole("button", { name: "Cancel" }).click();
+ await page.waitForTimeout(1000);
+ });
+
+ test("should test move event to new location", async ({ page }) => {
+ // Wait for the map to be fully loaded
+ await page.waitForSelector('[aria-label="Map"]', { timeout: 30000 });
+
+ // Get the map dimensions
+ const mapContainer = await page.$('[aria-label="Map"]');
+ const { width, height } = (await mapContainer?.boundingBox()) ?? {
+ width: 0,
+ height: 0,
+ };
+
+ // Click on yet another spot on the map
+ await page.mouse.click(width / 2 - 100, height / 2 + 100);
+ await page.waitForTimeout(1000);
+
+ // Verify the update pane marker is visible
+ const updatePaneMarker = page.getByTestId(TestId.UPDATE_PANE_MARKER);
+ await expect(updatePaneMarker).toBeVisible({ timeout: 5000 });
+
+ // Click on the marker to open the update pane
+ await updatePaneMarker.click();
+ await page.waitForTimeout(1000);
+
+ // Click on "Move existing event here" button
+ const moveEventButton = page.getByRole("button", {
+ name: "Move existing event here",
+ exact: true,
+ });
+ await expect(moveEventButton).toBeVisible({ timeout: 5000 });
+ await moveEventButton.click();
+ await page.waitForTimeout(1000);
+
+ // Select region (just to test the UI, not actually submitting)
+ await page.getByRole("combobox").first().click();
+ await page.keyboard.type("Boone");
+ await page.waitForTimeout(500);
+ await page.getByRole("option", { name: "Boone" }).first().click();
+ await page.waitForTimeout(500);
+
+ // Fill in some location details
+ await page
+ .locator('input[name="locationAddress"]')
+ .fill("789 Event Move Street");
+
+ // Cancel the operation
+ await page.getByRole("button", { name: "Cancel" }).click();
+ await page.waitForTimeout(1000);
+ });
+});
diff --git a/apps/map/e2e/setup/auth.spec.ts b/apps/map/e2e/setup/auth.spec.ts
new file mode 100644
index 00000000..8a623cc3
--- /dev/null
+++ b/apps/map/e2e/setup/auth.spec.ts
@@ -0,0 +1,32 @@
+import fs from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
+import { expect, test as setup } from "@playwright/test";
+
+// Convert the URL into a file path
+const __filename = fileURLToPath(import.meta.url);
+// Get the directory name
+const __dirname = path.dirname(__filename);
+
+// Create the auth directory if it doesn't exist
+const authDir = path.join(__dirname, ".auth");
+if (!fs.existsSync(authDir)) {
+ fs.mkdirSync(authDir, { recursive: true });
+}
+
+const authFile = path.join(__dirname, ".auth/user.json");
+
+setup("authenticate", async ({ page }) => {
+ // Perform authentication steps
+ await page.goto("/?lat=36.211104&lng=-81.660849&zoom=3");
+ await page.getByRole("button", { name: "Settings" }).click();
+ await page.getByRole("button", { name: "Sign in (Dev Mode)" }).click();
+
+ // Wait to ensure we're authenticated
+ await expect(page.getByRole("button", { name: "Settings" })).toBeVisible();
+
+ // Save authentication state to file
+ await page.context().storageState({ path: authFile });
+
+ console.log("Authentication completed and state saved to:", authFile);
+});
diff --git a/apps/map/e2e/tests/image.png b/apps/map/e2e/tests/image.png
new file mode 100644
index 00000000..07839be7
Binary files /dev/null and b/apps/map/e2e/tests/image.png differ
diff --git a/apps/map/package.json b/apps/map/package.json
index e44bd8a2..fef7999a 100644
--- a/apps/map/package.json
+++ b/apps/map/package.json
@@ -7,6 +7,7 @@
"build": "pnpm with-env next build",
"clean": "rm -rf .turbo node_modules .next",
"dev": "TZ=UTC pnpm with-env next dev",
+ "dev:inspect": "NODE_OPTIONS='--inspect' TZ=UTC pnpm with-env next dev",
"format": "prettier --check . --ignore-path ../../.gitignore --ignore-path ../../.prettierignore",
"lint": "dotenv -v SKIP_ENV_VALIDATION=1 next lint",
"start": "pnpm with-env next start",
diff --git a/apps/map/playwright.config.ts b/apps/map/playwright.config.ts
index 35e4925d..41a74266 100644
--- a/apps/map/playwright.config.ts
+++ b/apps/map/playwright.config.ts
@@ -1,11 +1,14 @@
import type { PlaywrightTestConfig } from "@playwright/test";
+import { devices } from "@playwright/test";
+
+// TODO: Better separation of auth and no auth tests
const config: PlaywrightTestConfig = {
- testDir: "./tests",
+ testDir: "./e2e",
timeout: 120000,
use: {
baseURL: "http://localhost:3000",
- headless: false,
+ headless: true,
viewport: { width: 1280, height: 720 },
screenshot: "only-on-failure",
trace: "retain-on-failure",
@@ -18,6 +21,37 @@ const config: PlaywrightTestConfig = {
reuseExistingServer: !process.env.CI,
},
retries: 1,
+ projects: [
+ { name: "setup", testMatch: /setup\/.+\.spec\.ts/ },
+ {
+ name: "chromium with auth",
+ testMatch: /auth\/.+\.spec\.ts/,
+ use: {
+ ...devices["Desktop Chrome"],
+ // Use prepared auth state.
+ storageState: "./e2e/setup/.auth/user.json",
+ },
+ // Only run this if we need to reauthenticate
+ // dependencies: ["setup"],
+ },
+ // {
+ // name: "chromium no auth",
+ // testMatch: /.*-no-auth\.spec\.ts/,
+ // use: {
+ // ...devices["Desktop Chrome"],
+ // },
+ // },
+ // {
+ // name: "chromium with auth",
+ // testMatch: /.*-auth\.spec\.ts/,
+ // use: {
+ // ...devices["Desktop Chrome"],
+ // // Use prepared auth state.
+ // storageState: "./e2e/.auth/user.json",
+ // },
+ // dependencies: ["setup"],
+ // },
+ ],
};
export default config;
diff --git a/apps/map/playwright.staging.config.ts b/apps/map/playwright.staging.config.ts
index 98a05565..ced65489 100644
--- a/apps/map/playwright.staging.config.ts
+++ b/apps/map/playwright.staging.config.ts
@@ -1,15 +1,13 @@
import type { PlaywrightTestConfig } from "@playwright/test";
-import baseConfig from "./playwright.config";
-
const config: PlaywrightTestConfig = {
- ...baseConfig,
+ testDir: "./e2e",
+ timeout: 120000,
use: {
- ...baseConfig.use,
baseURL: "https://staging.map.f3nation.com",
+ headless: true,
+ viewport: { width: 1280, height: 720 },
},
- // Don't start a web server for staging tests
- webServer: undefined,
};
export default config;
diff --git a/apps/map/src/app/_components/forms/dev-debug-component.tsx b/apps/map/src/app/_components/forms/dev-debug-component.tsx
new file mode 100644
index 00000000..23f615cf
--- /dev/null
+++ b/apps/map/src/app/_components/forms/dev-debug-component.tsx
@@ -0,0 +1,117 @@
+import { useFormContext } from "react-hook-form";
+
+import { Button } from "@acme/ui/button";
+
+interface DevLoadTestDataValues {
+ id: string;
+ eventName: string;
+ eventDayOfWeek: string;
+ submittedBy: string;
+ aoLogo: string;
+ aoName: string;
+ locationAddress: string;
+ locationAddress2: string;
+ locationCity: string;
+ locationState: string;
+ locationZip: string;
+ locationCountry: string;
+ eventTypeIds: number[];
+ eventStartTime: string;
+ eventEndTime: string;
+ eventDescription: string;
+ locationDescription: string;
+ aoWebsite: string;
+}
+export const DevLoadTestData = <_T extends DevLoadTestDataValues>() => {
+ const form = useFormContext();
+ return (
+
+ );
+};
+
+interface FormDebugValues {
+ id: string;
+ originalEventId?: string;
+ newEventId?: string;
+
+ originalLocationId?: string;
+ newLocationId?: string;
+
+ originalAoId?: string;
+ newAoId?: string;
+
+ originalRegionId?: string;
+ newRegionId?: string;
+}
+export const FormDebugData = <_T extends FormDebugValues>() => {
+ const form = useFormContext();
+ const formId = form.watch("id");
+ const formOriginalEventId = form.watch("originalEventId");
+ const formNewEventId = form.watch("newEventId");
+
+ const formOriginalLocationId = form.watch("originalLocationId");
+ const formNewLocationId = form.watch("newLocationId");
+
+ const formOriginalAoId = form.watch("originalAoId");
+ const formNewAoId = form.watch("newAoId");
+
+ const formOriginalRegionId = form.watch("originalRegionId");
+ const formNewRegionId = form.watch("newRegionId");
+ return (
+
+
formId: {formId};
+
+ originalEventId: {formOriginalEventId};
+
+
+ newEventId: {formNewEventId};
+
+
+ originalLocationId: {formOriginalLocationId};
+
+
+ newLocationId: {formNewLocationId};
+
+
+ originalAoId: {formOriginalAoId};
+
+
newAoId: {formNewAoId};
+
+ originalRegionId: {formOriginalRegionId};
+
+
+ newRegionId: {formNewRegionId};
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/forms/form-inputs/ao-details-form.tsx b/apps/map/src/app/_components/forms/form-inputs/ao-details-form.tsx
new file mode 100644
index 00000000..36c7ad2a
--- /dev/null
+++ b/apps/map/src/app/_components/forms/form-inputs/ao-details-form.tsx
@@ -0,0 +1,110 @@
+import { Controller, useFormContext } from "react-hook-form";
+
+import { Input } from "@acme/ui/input";
+
+import { scaleAndCropImage } from "~/utils/image/scale-and-crop-image";
+import { uploadLogo } from "~/utils/image/upload-logo";
+import { DebouncedImage } from "../../debounced-image";
+
+interface AODetailsFormValues {
+ aoName?: string;
+ aoWebsite?: string | null;
+ aoLogo?: string | null;
+ originalRegionId: number;
+ id: string;
+ badImage: boolean;
+}
+
+export const AODetailsForm = <_T extends AODetailsFormValues>() => {
+ const form = useFormContext();
+ const formOriginalRegionId = form.watch("originalRegionId");
+ const formId = form.watch("id");
+ return (
+ <>
+
+ New AO Details:
+
+
+
+
AO Name
+
+
+ {form.formState.errors.aoName?.message?.toString()}
+
+
+
+
+
+ AO Website
+
+
+
+ {form.formState.errors.aoWebsite?.message?.toString()}
+
+
+
+
+
+
+ AO Logo
+
+
{
+ return (
+
+
{
+ console.log("files", e.target.files);
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ const blob640 = await scaleAndCropImage(file, 640, 640);
+ if (!blob640) return;
+ const url640 = await uploadLogo({
+ file: blob640,
+ regionId: formOriginalRegionId ?? 0,
+ requestId: formId,
+ });
+ onChange(url640);
+ const blob64 = await scaleAndCropImage(file, 64, 64);
+ if (blob64) {
+ void uploadLogo({
+ file: blob64,
+ regionId: formOriginalRegionId ?? 0,
+ requestId: formId ?? "",
+ size: 64,
+ });
+ }
+ }}
+ disabled={
+ typeof formOriginalRegionId !== "number" ||
+ formOriginalRegionId <= -1
+ }
+ className="flex-1"
+ />
+ {value && (
+
+ form.setValue("badImage", true)}
+ onImageSuccess={() => form.setValue("badImage", false)}
+ width={96}
+ height={96}
+ />
+
+ )}
+
+ );
+ }}
+ />
+
+
+ >
+ );
+};
diff --git a/apps/map/src/app/_components/forms/form-inputs/ao-selector.tsx b/apps/map/src/app/_components/forms/form-inputs/ao-selector.tsx
new file mode 100644
index 00000000..92480bc7
--- /dev/null
+++ b/apps/map/src/app/_components/forms/form-inputs/ao-selector.tsx
@@ -0,0 +1,108 @@
+import { Controller, useFormContext } from "react-hook-form";
+
+import { orpc, useQuery } from "~/orpc/react";
+import { VirtualizedCombobox } from "../../virtualized-combobox";
+
+interface AOSelectorProps {
+ label?: string;
+ fieldName?: "originalAoId" | "newAoId";
+ regionFieldName?: "originalRegionId" | "newRegionId";
+}
+
+interface AOSelectorFormValues {
+ originalRegionId?: number | null;
+ newRegionId?: number | null;
+ originalAoId?: number | null;
+ newAoId?: number | null;
+ originalEventId?: number | null;
+ newEventId?: number | null;
+}
+
+/**
+ * AO selector component - handles AO selection based on selected region
+ * Single Responsibility: Display and manage AO selection
+ * Depends on region being selected first
+ * Side Effects: Clears dependent Event field when AO changes
+ */
+export function AOSelector<_T extends AOSelectorFormValues>({
+ label = "From AO (optional):",
+ fieldName = "newAoId",
+ regionFieldName = "newRegionId",
+}: AOSelectorProps) {
+ const form = useFormContext();
+ const regionId = form.watch(regionFieldName);
+
+ const { data: results } = useQuery(
+ orpc.map.location.getAOsInRegion.queryOptions({
+ input: { regionId: regionId ?? -1 },
+ enabled: regionId != null,
+ }),
+ );
+
+ const aoOptions =
+ results?.aos
+ ?.map((ao) => ({
+ label: ao.name,
+ value: ao.id.toString(),
+ labelComponent: (
+
+
{ao.name}
+ {Array.isArray(ao.workouts) && ao.workouts.length > 0 && (
+
+ {ao.workouts
+ .filter((w): w is string => typeof w === "string")
+ .join(", ")}
+
+ )}
+
+ ),
+ }))
+ .sort((a, b) => a.label.localeCompare(b.label)) ?? [];
+
+ // Determine which event field to clear based on the AO field
+ const getEventField = () => {
+ if (fieldName === "originalAoId") {
+ return "originalEventId" as const;
+ }
+ return "newEventId" as const;
+ };
+
+ const eventField = getEventField();
+
+ return (
+
+
{label}
+
(
+ <>
+ {
+ const ao = results?.aos?.find(
+ (ao) => ao.id.toString() === item,
+ );
+ const newValue = ao?.id ?? null;
+
+ // Set the AO
+ field.onChange(newValue);
+
+ // Clear dependent event field when AO changes
+ if (field.value !== newValue) {
+ form.setValue(eventField, null);
+ }
+ }}
+ searchPlaceholder="Select AO"
+ />
+
+ {fieldState.error?.message?.toString()}
+
+ >
+ )}
+ />
+
+ );
+}
diff --git a/apps/map/src/app/_components/forms/form-inputs/contact-details-form.tsx b/apps/map/src/app/_components/forms/form-inputs/contact-details-form.tsx
new file mode 100644
index 00000000..ff311da5
--- /dev/null
+++ b/apps/map/src/app/_components/forms/form-inputs/contact-details-form.tsx
@@ -0,0 +1,38 @@
+import { useFormContext } from "react-hook-form";
+
+import { Input } from "@acme/ui/input";
+
+import { useAuth } from "~/utils/hooks/use-auth";
+
+interface ContactDetailsFormValues {
+ submittedBy: string | null;
+}
+export const ContactDetailsForm = <_T extends ContactDetailsFormValues>() => {
+ const form = useFormContext();
+ const { session } = useAuth();
+
+ return (
+ <>
+
+ Contact Information:
+
+
+
+ Your Email
+
+
+
+ We will send you a confirmation email when your update request is
+ approved.
+
+
+ {form.formState.errors.submittedBy?.message?.toString()}
+
+
+ >
+ );
+};
diff --git a/apps/map/src/app/_components/forms/form-inputs/controlled-time-input.tsx b/apps/map/src/app/_components/forms/form-inputs/controlled-time-input.tsx
new file mode 100644
index 00000000..9f0cab1c
--- /dev/null
+++ b/apps/map/src/app/_components/forms/form-inputs/controlled-time-input.tsx
@@ -0,0 +1,61 @@
+import type { FieldPath, FieldValues } from "react-hook-form";
+import { Controller, useFormContext } from "react-hook-form";
+
+import { FormControl, FormItem, FormLabel, FormMessage } from "@acme/ui/form";
+import { Input } from "@acme/ui/input";
+
+interface TimeInputProps {
+ name: FieldPath;
+ id?: string;
+ label: string;
+}
+
+export const ControlledTimeInput = ({
+ name,
+ id,
+ label,
+}: TimeInputProps) => {
+ const form = useFormContext();
+
+ return (
+ (
+
+ {label}
+
+ {
+ const timeValue = e.target.value;
+ // Convert "05:30" -> "0530" before storing in form
+ if (timeValue && timeValue.includes(":")) {
+ const [h, m] = timeValue.split(":");
+ field.onChange(
+ (h?.padStart(2, "0") ?? "") + (m?.padStart(2, "0") ?? ""),
+ );
+ } else {
+ field.onChange(timeValue);
+ }
+ }}
+ className="w-full"
+ />
+
+
+
+ )}
+ />
+ );
+};
diff --git a/apps/map/src/app/_components/forms/form-inputs/delete-ao-form.tsx b/apps/map/src/app/_components/forms/form-inputs/delete-ao-form.tsx
new file mode 100644
index 00000000..79e9c462
--- /dev/null
+++ b/apps/map/src/app/_components/forms/form-inputs/delete-ao-form.tsx
@@ -0,0 +1,77 @@
+import { useFormContext } from "react-hook-form";
+
+import type { DeleteAOType } from "@acme/validators/request-schemas";
+
+import { orpc, useQuery } from "~/orpc/react";
+
+export const DeleteAoForm = <_T extends DeleteAOType>() => {
+ const form = useFormContext();
+ const originalAoId = form.watch("originalAoId");
+ const { data: result } = useQuery(
+ orpc.org.byId.queryOptions({
+ input: { id: originalAoId, orgType: "ao" },
+ enabled: !!originalAoId,
+ }),
+ );
+ return (
+
+
+ {/* AO Details Section */}
+
+
+
+
+ {result?.name ? (
+ result.name
+ ) : (
+ AO name loading...
+ )}
+
+
+ AO ID: {originalAoId}
+
+
+
+ {/* Warning Section */}
+
+
+ Attention Required
+
+
+
+ You are about to request deletion of an AO.
+
+
+ This will delete{" "}
+
+ {result?.name ? result.name : "this AO"}
+
+ , all its workouts, and possibly the location if no other events
+ exist there.
+
+
+ This action cannot be undone. Please confirm you want to proceed
+ with this deletion request.
+
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/forms/form-inputs/delete-event-form.tsx b/apps/map/src/app/_components/forms/form-inputs/delete-event-form.tsx
new file mode 100644
index 00000000..58987b55
--- /dev/null
+++ b/apps/map/src/app/_components/forms/form-inputs/delete-event-form.tsx
@@ -0,0 +1,70 @@
+import { useFormContext } from "react-hook-form";
+
+import type { DeleteEventType } from "@acme/validators/request-schemas";
+
+import { orpc, useQuery } from "~/orpc/react";
+
+export const DeleteEventForm = <_T extends DeleteEventType>() => {
+ const form = useFormContext();
+ const originalEventId = form.watch("originalEventId");
+ const { data: event } = useQuery(
+ orpc.event.byId.queryOptions({
+ input: { id: originalEventId },
+ enabled: !!originalEventId,
+ }),
+ );
+ return (
+
+
+ {/* Event Details Section */}
+
+
+
+
+ {event?.name ? (
+ event.name
+ ) : (
+
+ Event name loading...
+
+ )}
+
+
+ Event ID: {originalEventId}
+
+
+
+ {/* Warning Section */}
+
+
+ Attention Required
+
+
+
+ You are about to request deletion of an event.
+
+ This action cannot be undone. Please confirm you want to proceed
+ with this deletion request.
+
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/forms/form-inputs/event-details-form.tsx b/apps/map/src/app/_components/forms/form-inputs/event-details-form.tsx
new file mode 100644
index 00000000..7d37400f
--- /dev/null
+++ b/apps/map/src/app/_components/forms/form-inputs/event-details-form.tsx
@@ -0,0 +1,200 @@
+import { Controller, useFormContext } from "react-hook-form";
+
+import type { EventFieldsType } from "@acme/validators/request-schemas";
+import { DayOfWeek } from "@acme/shared/app/enums";
+import { Case } from "@acme/shared/common/enums";
+import { convertCase } from "@acme/shared/common/functions";
+import { Input } from "@acme/ui/input";
+import { MultiSelect } from "@acme/ui/multi-select";
+import { ControlledSelect } from "@acme/ui/select";
+import { Textarea } from "@acme/ui/textarea";
+
+import { orpc, useQuery } from "~/orpc/react";
+import { ControlledTimeInput } from "./controlled-time-input";
+
+interface EventDetailsFormValues {
+ originalRegionId: number;
+ eventName?: string;
+ eventDayOfWeek?: DayOfWeek | null;
+ eventTypeIds?: number[];
+ eventStartTime?: string;
+ eventEndTime?: string;
+ eventDescription?: string;
+ currentValues?: Partial;
+}
+
+export const EventDetailsForm = <_T extends EventDetailsFormValues>() => {
+ // I'd like for this to be generic, but the types don't seem to be working as expected.
+ const form = useFormContext();
+ const formRegionId = form.watch("originalRegionId");
+ const currentValues = form.watch("currentValues");
+ const formEventDayOfWeek = form.watch("eventDayOfWeek");
+ const formEventStartTime = form.watch("eventStartTime");
+ const formEventEndTime = form.watch("eventEndTime");
+ const formEventTypeIds = form.watch("eventTypeIds");
+ const formEventDescription = form.watch("eventDescription");
+ const formEventName = form.watch("eventName");
+
+ // Get event types for the region
+ const { data: eventTypes } = useQuery(
+ orpc.eventType.all.queryOptions({
+ input: { orgIds: formRegionId ? [formRegionId] : [] },
+ enabled: formRegionId != null,
+ }),
+ );
+
+ return (
+ <>
+
+ Event Details:
+
+
+
+
+ Workout Name
+
+
+ {currentValues?.eventName &&
+ currentValues.eventName !== formEventName && (
+
+ {currentValues.eventName}
+
+ )}
+
+ {form.formState.errors.eventName?.message}
+
+
+
+
+
+ Day of Week
+
+
({
+ value: day,
+ label: convertCase({
+ str: day,
+ fromCase: Case.LowerCase,
+ toCase: Case.TitleCase,
+ }),
+ }))}
+ placeholder="Select a day of the week"
+ />
+ {currentValues?.eventDayOfWeek &&
+ currentValues.eventDayOfWeek !== formEventDayOfWeek && (
+
+ {currentValues.eventDayOfWeek}
+
+ )}
+
+ {form.formState.errors.eventDayOfWeek?.message}
+
+
+
+
+
+ {currentValues?.eventStartTime &&
+ currentValues.eventStartTime !== formEventStartTime && (
+
+ {formatTime(currentValues.eventStartTime)}
+
+ )}
+
+
+
+ {currentValues?.eventEndTime &&
+ currentValues.eventEndTime !== formEventEndTime && (
+
+ {formatTime(currentValues.eventEndTime)}
+
+ )}
+
+
+
+ Event Types
+
+
{
+ return (
+
+
({
+ label: type.name,
+ value: type.id.toString(),
+ })) ?? []
+ }
+ onValueChange={(values) =>
+ field.onChange(values.map(Number))
+ }
+ placeholder="Select event types"
+ />
+ {fieldState.error && (
+
+ You must select at least one event type
+
+ )}
+
+ );
+ }}
+ />
+ {currentValues?.eventTypeIds &&
+ JSON.stringify(currentValues.eventTypeIds) !==
+ JSON.stringify(formEventTypeIds) && (
+
+ {currentValues.eventTypeIds
+ .map(
+ (id) =>
+ eventTypes?.eventTypes.find((type) => type.id === id)
+ ?.name,
+ )
+ .join(", ")}
+
+ )}
+
+
+
+
+ Event Description
+
+
+ {currentValues?.eventDescription !== formEventDescription && (
+
+ {currentValues?.eventDescription}
+
+ )}
+
+ {form.formState.errors.eventDescription?.message}
+
+
+
+ >
+ );
+};
+
+const formatTime = (time: string) => {
+ if (!/^\d{4}$/.test(time)) return time;
+ let hours = parseInt(time.slice(0, 2), 10);
+ const minutes = time.slice(2, 4);
+ const period = hours >= 12 ? "pm" : "am";
+ hours = hours % 12 === 0 ? 12 : hours % 12;
+ return `${hours}:${minutes}${period}`;
+};
diff --git a/apps/map/src/app/_components/forms/form-inputs/event-selector.tsx b/apps/map/src/app/_components/forms/form-inputs/event-selector.tsx
new file mode 100644
index 00000000..34af95c1
--- /dev/null
+++ b/apps/map/src/app/_components/forms/form-inputs/event-selector.tsx
@@ -0,0 +1,87 @@
+import { Controller, useFormContext } from "react-hook-form";
+
+import { dayOfWeekToShortDayOfWeek } from "@acme/shared/app/functions";
+
+import { orpc, useQuery } from "~/orpc/react";
+import { useOptions } from "~/utils/use-options";
+import { VirtualizedCombobox } from "../../virtualized-combobox";
+
+interface EventSelectorProps {
+ label?: string;
+ fieldName?: "originalEventId" | "newEventId";
+ regionFieldName?: "originalRegionId" | "newRegionId";
+ aoFieldName?: "originalAoId" | "newAoId";
+}
+
+interface EventSelectorFormValues {
+ originalRegionId?: number | null;
+ newRegionId?: number | null;
+ originalAoId?: number | null;
+ newAoId?: number | null;
+ originalEventId?: number | null;
+ newEventId?: number | null;
+}
+
+/**
+ * Event selector component - handles event selection based on selected region/AO
+ * Single Responsibility: Display and manage event selection
+ * Depends on region being selected first
+ */
+export function EventSelector<_T extends EventSelectorFormValues>({
+ label = "Event to move:",
+ fieldName = "newEventId",
+ regionFieldName = "newRegionId",
+ aoFieldName = "newAoId",
+}: EventSelectorProps) {
+ const form = useFormContext();
+ const regionId = form.watch(regionFieldName);
+ const aoId = form.watch(aoFieldName);
+
+ const { data: events } = useQuery(
+ orpc.event.all.queryOptions({
+ input: {
+ ...(aoId ? { aoIds: [aoId] } : {}),
+ ...(regionId ? { regionIds: [regionId] } : {}),
+ },
+ enabled: regionId != null,
+ }),
+ );
+
+ const eventOptions = useOptions(
+ events?.events,
+ (e) =>
+ e.dayOfWeek
+ ? `${e.name} (${dayOfWeekToShortDayOfWeek(e.dayOfWeek)})`
+ : e.name,
+ (e) => e.id.toString(),
+ );
+
+ return (
+
+
{label}
+
(
+ <>
+ {
+ const event = events?.events?.find(
+ (event) => event.id.toString() === item,
+ );
+ field.onChange(event?.id ?? null);
+ }}
+ searchPlaceholder="Select Event"
+ />
+
+ {fieldState.error?.message?.toString()}
+
+ >
+ )}
+ />
+
+ );
+}
diff --git a/apps/map/src/app/_components/forms/form-inputs/existing-location-picker-form.tsx b/apps/map/src/app/_components/forms/form-inputs/existing-location-picker-form.tsx
new file mode 100644
index 00000000..2918db2a
--- /dev/null
+++ b/apps/map/src/app/_components/forms/form-inputs/existing-location-picker-form.tsx
@@ -0,0 +1,120 @@
+import { useMemo } from "react";
+import { PlusCircle } from "lucide-react";
+import { Controller, useFormContext } from "react-hook-form";
+
+import { isTruthy } from "@acme/shared/common/functions";
+
+import { orpc, useQuery } from "~/orpc/react";
+import { VirtualizedCombobox } from "../../virtualized-combobox";
+
+const NEW_LOCATION_VALUE = "new";
+
+interface ExistingLocationPickerFormValues {
+ newLocationId: number | null;
+ originalLocationId: number;
+ originalRegionId: number;
+ newRegionId?: number;
+}
+
+export const ExistingLocationPickerForm = <
+ _T extends ExistingLocationPickerFormValues,
+>(params: {
+ region: "originalRegion" | "newRegion";
+}) => {
+ const form = useFormContext();
+ const formNewRegionId = form.watch("newRegionId");
+ const formOriginalRegionId = form.watch("originalRegionId");
+
+ const disabled =
+ (params.region === "newRegion" && !formNewRegionId) ||
+ (params.region === "originalRegion" && !formOriginalRegionId);
+
+ // Get location data
+ const { data: locations } = useQuery(orpc.location.all.queryOptions());
+
+ const sortedRegionLocationOptions = useMemo(() => {
+ const newLocationOption = {
+ labelComponent: (
+
+
+ Create new location
+
+ ),
+ label: "Create new location",
+ value: NEW_LOCATION_VALUE,
+ regionId: null,
+ pinned: true,
+ };
+
+ const existingLocations =
+ locations?.locations
+ // If we have an original regionId, only show those, otherwise use the formRegionId
+ ?.filter((l) =>
+ params.region === "originalRegion"
+ ? l.regionId === formOriginalRegionId
+ : l.regionId === formNewRegionId,
+ )
+ ?.sort((a, b) => a.locationName.localeCompare(b.locationName))
+ ?.map((l) => ({
+ labelComponent: (
+
+ {`${l.locationName}${l.regionName ? ` (${l.regionName})` : ""}`}
+ {` ${[l.addressStreet, l.addressStreet2, l.addressCity, l.addressState, l.addressZip, l.addressCountry].filter(isTruthy).join(", ")}`}
+
+ ),
+ label: `${l.locationName}${l.regionName ? ` (${l.regionName})` : ""} ${[l.addressStreet, l.addressStreet2, l.addressCity, l.addressState, l.addressZip, l.addressCountry].filter(isTruthy).join(", ")}`,
+ value: l.id.toString(),
+ regionId: l.regionId,
+ })) ?? [];
+
+ return [newLocationOption, ...existingLocations];
+ }, [
+ locations?.locations,
+ params.region,
+ formOriginalRegionId,
+ formNewRegionId,
+ ]);
+
+ return (
+ <>
+
+ Select Destination Location:
+
+
+
+
+ Location
+
+
(
+
+
{
+ if (value === NEW_LOCATION_VALUE) {
+ field.onChange(null);
+ } else {
+ field.onChange(Number(value));
+ }
+ }}
+ searchPlaceholder="Select destination location"
+ />
+
+ {form.formState.errors.newLocationId?.message?.toString()}
+
+
+ )}
+ />
+
+
+ >
+ );
+};
diff --git a/apps/map/src/app/_components/forms/form-inputs/in-region-form.tsx b/apps/map/src/app/_components/forms/form-inputs/in-region-form.tsx
new file mode 100644
index 00000000..1d4659e5
--- /dev/null
+++ b/apps/map/src/app/_components/forms/form-inputs/in-region-form.tsx
@@ -0,0 +1,66 @@
+import { Controller, useFormContext } from "react-hook-form";
+
+import { orpc, useQuery } from "~/orpc/react";
+import { useOptions } from "~/utils/use-options";
+import { VirtualizedCombobox } from "../../virtualized-combobox";
+
+interface InRegionFormValues {
+ originalRegionId?: number;
+}
+
+// TODO: Fix selection form for all use cases
+export const InRegionForm = <_T extends InRegionFormValues>() => {
+ const form = useFormContext();
+
+ const { data: regions } = useQuery(
+ orpc.org.all.queryOptions({ input: { orgTypes: ["region"] } }),
+ );
+
+ const regionOptions = useOptions(
+ regions?.orgs,
+ (r) => r.name,
+ (r) => r.id.toString(),
+ );
+
+ return (
+ <>
+
+ In Region:
+
+
+
+
+ Region:
+
+
(
+ <>
+ {
+ const region = regions?.orgs.find(
+ (region) => region.id.toString() === item,
+ );
+ form.setValue(
+ "originalRegionId",
+ // @ts-expect-error - need to unset regionId despite zod
+ region?.id ?? (null as number),
+ );
+ }}
+ searchPlaceholder="Select Region"
+ />
+
+ {fieldState.error?.message?.toString()}
+
+ >
+ )}
+ />
+
+
+ >
+ );
+};
diff --git a/apps/map/src/app/_components/forms/form-inputs/location-details-form.tsx b/apps/map/src/app/_components/forms/form-inputs/location-details-form.tsx
new file mode 100644
index 00000000..baff8f05
--- /dev/null
+++ b/apps/map/src/app/_components/forms/form-inputs/location-details-form.tsx
@@ -0,0 +1,132 @@
+import { Controller, useFormContext } from "react-hook-form";
+
+import { Input } from "@acme/ui/input";
+import { Textarea } from "@acme/ui/textarea";
+
+import { CountrySelect } from "../../modal/country-select";
+
+interface LocationDetailsFormValues {
+ locationAddress?: string;
+ locationAddress2?: string | null;
+ locationCity?: string | null;
+ locationState?: string | null;
+ locationZip?: string | null;
+ locationCountry?: string | null;
+ locationLat?: number | null;
+ locationLng?: number | null;
+ locationDescription?: string | null;
+}
+
+export const LocationDetailsForm = <_T extends LocationDetailsFormValues>() => {
+ const form = useFormContext();
+
+ return (
+ <>
+
+ Physical Location Details:
+
+
+
+
+ Street Address
+
+
+
+ {form.formState.errors.locationAddress?.message?.toString()}
+
+
+
+
+ Address Line 2
+
+
+
+ {form.formState.errors.locationAddress2?.message?.toString()}
+
+
+
+
+
City
+
+
+ {form.formState.errors.locationCity?.message?.toString()}
+
+
+
+
+ State/Province
+
+
+
+ {form.formState.errors.locationState?.message?.toString()}
+
+
+
+
+
+ ZIP / Postal Code
+
+
+
+ {form.formState.errors.locationZip?.message?.toString()}
+
+
+
+
+
+ Country
+
+
(
+
+ )}
+ />
+
+ {form.formState.errors.locationCountry?.message?.toString()}
+
+
+
+
+
+ Latitude
+
+
+
+ {form.formState.errors.locationLat?.message?.toString()}
+
+
+
+
+ Longitude
+
+
+
+ {form.formState.errors.locationLng?.message?.toString()}
+
+
+
+
+
+ Location Description
+
+
+
+
+
+ {form.formState.errors.locationDescription?.message?.toString()}
+
+
+
+ >
+ );
+};
diff --git a/apps/map/src/app/_components/forms/form-inputs/region-and-ao-selector.tsx b/apps/map/src/app/_components/forms/form-inputs/region-and-ao-selector.tsx
new file mode 100644
index 00000000..b9d39132
--- /dev/null
+++ b/apps/map/src/app/_components/forms/form-inputs/region-and-ao-selector.tsx
@@ -0,0 +1,60 @@
+import { AOSelector } from "./ao-selector";
+import { RegionSelector } from "./region-selector";
+
+interface RegionAndAOSelectorProps {
+ title?: string;
+ regionLabel?: string;
+ aoLabel?: string;
+ regionFieldName?: "originalRegionId" | "newRegionId";
+ aoFieldName?: "originalAoId" | "newAoId";
+ /** Set to true when moving within the same region (syncs originalRegionId with newRegionId) */
+ sameRegionMove?: boolean;
+}
+
+interface RegionAndAOSelectorFormValues {
+ originalRegionId?: number | null;
+ newRegionId?: number | null;
+ originalAoId?: number | null;
+ newAoId?: number | null;
+}
+
+/**
+ * Composed component for Region + AO selection
+ * Follows Open/Closed Principle: New selection combinations can be added
+ * without modifying existing components
+ */
+export function RegionAndAOSelector<_T extends RegionAndAOSelectorFormValues>({
+ title = "Choose AO:",
+ regionLabel = "In Region:",
+ aoLabel = "AO to move:",
+ regionFieldName = "newRegionId",
+ aoFieldName = "newAoId",
+ sameRegionMove = false,
+}: RegionAndAOSelectorProps) {
+ // For same-region moves, sync the selected region with both original and new
+ const syncField = sameRegionMove
+ ? regionFieldName === "newRegionId"
+ ? "originalRegionId"
+ : "newRegionId"
+ : undefined;
+
+ return (
+ <>
+
+ {title}
+
+
+
+ label={regionLabel}
+ fieldName={regionFieldName}
+ syncWithField={syncField}
+ />
+
+ label={aoLabel}
+ fieldName={aoFieldName}
+ regionFieldName={regionFieldName}
+ />
+
+ >
+ );
+}
diff --git a/apps/map/src/app/_components/forms/form-inputs/region-and-event-selector.tsx b/apps/map/src/app/_components/forms/form-inputs/region-and-event-selector.tsx
new file mode 100644
index 00000000..d1636f68
--- /dev/null
+++ b/apps/map/src/app/_components/forms/form-inputs/region-and-event-selector.tsx
@@ -0,0 +1,39 @@
+import { EventSelector } from "./event-selector";
+import { RegionSelector } from "./region-selector";
+
+interface RegionAndEventSelectorProps {
+ title?: string;
+ regionLabel?: string;
+ eventLabel?: string;
+ regionFieldName?: "originalRegionId" | "newRegionId";
+ eventFieldName?: "originalEventId" | "newEventId";
+}
+
+/**
+ * Composed component for Region + Event selection
+ * Follows Open/Closed Principle: New selection combinations can be added
+ * without modifying existing components
+ */
+export function RegionAndEventSelector({
+ title = "Choose Event:",
+ regionLabel = "In Region:",
+ eventLabel = "Event to move:",
+ regionFieldName = "newRegionId",
+ eventFieldName = "newEventId",
+}: RegionAndEventSelectorProps) {
+ return (
+ <>
+
+ {title}
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/map/src/app/_components/forms/form-inputs/region-ao-event-selector.tsx b/apps/map/src/app/_components/forms/form-inputs/region-ao-event-selector.tsx
new file mode 100644
index 00000000..c3489da1
--- /dev/null
+++ b/apps/map/src/app/_components/forms/form-inputs/region-ao-event-selector.tsx
@@ -0,0 +1,62 @@
+import { AOSelector } from "./ao-selector";
+import { EventSelector } from "./event-selector";
+import { RegionSelector } from "./region-selector";
+
+interface RegionAOEventSelectorProps {
+ title?: string;
+ regionLabel?: string;
+ aoLabel?: string;
+ eventLabel?: string;
+ regionFieldName?: "originalRegionId" | "newRegionId";
+ aoFieldName?: "originalAoId" | "newAoId";
+ eventFieldName?: "originalEventId" | "newEventId";
+}
+
+interface RegionAOEventSelectorFormValues {
+ originalRegionId?: number | null;
+ newRegionId?: number | null;
+ originalAoId?: number | null;
+ newAoId?: number | null;
+ originalEventId?: number | null;
+ newEventId?: number | null;
+}
+/**
+ * Composed component for Region + AO + Event selection
+ * Follows Open/Closed Principle: New selection combinations can be added
+ * without modifying existing components
+ */
+export function RegionAOEventSelector<
+ _T extends RegionAOEventSelectorFormValues,
+>({
+ title = "Choose Event:",
+ regionLabel = "In Region:",
+ aoLabel = "From AO (optional):",
+ eventLabel = "Event to move:",
+ regionFieldName = "newRegionId",
+ aoFieldName = "newAoId",
+ eventFieldName = "newEventId",
+}: RegionAOEventSelectorProps) {
+ return (
+ <>
+
+ {title}
+
+
+
label={regionLabel} fieldName={regionFieldName} />
+
+ label={aoLabel}
+ fieldName={aoFieldName}
+ regionFieldName={
+ regionFieldName as "originalRegionId" | "newRegionId"
+ }
+ />
+
+ label={eventLabel}
+ fieldName={eventFieldName}
+ regionFieldName={regionFieldName}
+ aoFieldName={aoFieldName}
+ />
+
+ >
+ );
+}
diff --git a/apps/map/src/app/_components/forms/form-inputs/region-selector.tsx b/apps/map/src/app/_components/forms/form-inputs/region-selector.tsx
new file mode 100644
index 00000000..5b247750
--- /dev/null
+++ b/apps/map/src/app/_components/forms/form-inputs/region-selector.tsx
@@ -0,0 +1,104 @@
+import { Controller, useFormContext } from "react-hook-form";
+
+import { orpc, useQuery } from "~/orpc/react";
+import { useOptions } from "~/utils/use-options";
+import { VirtualizedCombobox } from "../../virtualized-combobox";
+
+interface RegionSelectorProps {
+ label?: string;
+ fieldName?: "originalRegionId" | "newRegionId";
+ /** Optional field to sync with the same value (e.g., for moves within same region) */
+ syncWithField?: "originalRegionId" | "newRegionId";
+}
+
+interface RegionSelectorFormValues {
+ originalRegionId?: number | null;
+ newRegionId?: number | null;
+ originalAoId?: number | null;
+ newAoId?: number | null;
+ originalEventId?: number | null;
+ newEventId?: number | null;
+}
+
+/**
+ * Region selector component - handles region selection only
+ * Single Responsibility: Display and manage region selection
+ * Side Effects:
+ * - Clears dependent AO and Event fields when region changes
+ * - Optionally syncs value with another region field (for same-region moves)
+ */
+export function RegionSelector<_T extends RegionSelectorFormValues>({
+ label = "In Region:",
+ fieldName = "newRegionId",
+ syncWithField,
+}: RegionSelectorProps) {
+ const form = useFormContext();
+ const { data: regions } = useQuery(
+ orpc.org.all.queryOptions({ input: { orgTypes: ["region"] } }),
+ );
+
+ const regionOptions = useOptions(
+ regions?.orgs,
+ (r) => r.name,
+ (r) => r.id.toString(),
+ );
+
+ // Determine which dependent fields to clear based on the region field
+ const getDependentFields = () => {
+ if (fieldName === "originalRegionId") {
+ return {
+ aoField: "originalAoId" as const,
+ eventField: "originalEventId" as const,
+ };
+ }
+ return {
+ aoField: "newAoId" as const,
+ eventField: "newEventId" as const,
+ };
+ };
+
+ const { aoField, eventField } = getDependentFields();
+
+ return (
+
+
{label}
+
(
+ <>
+ {
+ const region = regions?.orgs.find(
+ (region) => region.id.toString() === item,
+ );
+ const newValue = region?.id ?? null;
+
+ // Set the primary region field
+ form.setValue(fieldName, newValue);
+
+ // Sync with another field if specified (e.g., for same-region moves)
+ if (syncWithField) {
+ form.setValue(syncWithField, newValue);
+ }
+
+ // Clear dependent fields when region changes
+ if (field.value !== newValue) {
+ form.setValue(aoField, null);
+ form.setValue(eventField, null);
+ }
+ }}
+ searchPlaceholder="Select Region"
+ />
+
+ {fieldState.error?.message?.toString()}
+
+ >
+ )}
+ />
+
+ );
+}
diff --git a/apps/map/src/app/_components/forms/form-inputs/selection-form.tsx b/apps/map/src/app/_components/forms/form-inputs/selection-form.tsx
new file mode 100644
index 00000000..724ae1f6
--- /dev/null
+++ b/apps/map/src/app/_components/forms/form-inputs/selection-form.tsx
@@ -0,0 +1,200 @@
+import { Controller, useFormContext } from "react-hook-form";
+
+import { dayOfWeekToShortDayOfWeek } from "@acme/shared/app/functions";
+
+import { orpc, useQuery } from "~/orpc/react";
+import { useOptions } from "~/utils/use-options";
+import { VirtualizedCombobox } from "../../virtualized-combobox";
+
+interface SelectionFormProps {
+ title?: string;
+ regionLabel?: string;
+ aoLabel?: string;
+ eventLabel?: string;
+ includeRegion?: boolean;
+ includeAO?: boolean;
+ includeEvent?: boolean;
+}
+
+interface SelectionFormValues {
+ originalRegionId?: number | null;
+ originalAoId?: number | null;
+ originalEventId?: number | null;
+ newRegionId?: number | null;
+ newAoId?: number | null;
+ newEventId?: number | null;
+}
+
+/**
+ * @deprecated This component violates SOLID principles.
+ * Use the new focused components instead:
+ * - RegionSelector: For region selection only
+ * - AOSelector: For AO selection only
+ * - EventSelector: For event selection only
+ * - RegionAndAOSelector: For region + AO selection
+ * - RegionAndEventSelector: For region + event selection
+ * - RegionAOEventSelector: For region + AO + event selection
+ *
+ * These components follow the Single Responsibility Principle and are
+ * easier to test, maintain, and compose.
+ */
+export const SelectionForm = <_T extends SelectionFormValues>(
+ props: SelectionFormProps,
+) => {
+ const form = useFormContext();
+ const _formOriginalRegionId = form.watch("originalRegionId");
+ const formNewRegionId = form.watch("newRegionId");
+ const _formOriginalAOId = form.watch("originalAoId");
+ const formNewAOId = form.watch("newAoId");
+ const _formOriginalEventId = form.watch("originalEventId");
+ const _formNewEventId = form.watch("newEventId");
+
+ const { data: regions } = useQuery(
+ orpc.org.all.queryOptions({ input: { orgTypes: ["region"] } }),
+ );
+
+ const { data: events } = useQuery(
+ orpc.event.all.queryOptions({
+ input: {
+ ...(formNewAOId ? { aoIds: [formNewAOId] } : {}),
+ ...(formNewRegionId ? { regionIds: [formNewRegionId] } : {}),
+ },
+ enabled: formNewRegionId != null,
+ }),
+ );
+
+ const { data: aos } = useQuery(
+ orpc.org.all.queryOptions({
+ input: {
+ orgTypes: ["ao"],
+ parentOrgIds: formNewRegionId ? [formNewRegionId] : undefined,
+ pageSize: 200,
+ },
+ enabled: formNewRegionId != null,
+ }),
+ );
+
+ const regionOptions = useOptions(
+ regions?.orgs,
+ (r) => r.name,
+ (r) => r.id.toString(),
+ );
+ const aoOptions = useOptions(
+ aos?.orgs,
+ (ao) => `${ao.name} (${ao.parentOrgName})`,
+ (ao) => ao.id.toString(),
+ );
+
+ const eventOptions = useOptions(
+ events?.events,
+ (e) =>
+ e.dayOfWeek
+ ? `${e.name} (${dayOfWeekToShortDayOfWeek(e.dayOfWeek)})`
+ : e.name,
+ (e) => e.id.toString(),
+ );
+
+ return (
+ <>
+
+ {props.title ?? "Choose Event:"}
+
+
+ {props.includeRegion && (
+
+
+ {props.regionLabel ?? "In Region:"}
+
+
(
+ <>
+ {
+ const region = regions?.orgs.find(
+ (region) => region.id.toString() === item,
+ );
+ form.setValue(
+ "newRegionId",
+ // @ts-expect-error - need to unset regionId despite zod
+ region?.id ?? (null as number),
+ );
+ }}
+ searchPlaceholder="Select Region"
+ />
+
+ {fieldState.error?.message?.toString()}
+
+ >
+ )}
+ />
+
+ )}
+ {props.includeAO && (
+
+
+ {props.aoLabel ?? "From AO (optional):"}
+
+
(
+ <>
+ {
+ const ao = aos?.orgs?.find(
+ (ao) => ao.id.toString() === item,
+ );
+ field.onChange(ao?.id ?? null);
+ }}
+ searchPlaceholder="Select AO"
+ />
+
+ {fieldState.error?.message?.toString()}
+
+ >
+ )}
+ />
+
+ )}
+ {props.includeEvent && (
+
+
+ {props.eventLabel ?? "Event to move:"}
+
+
(
+ <>
+ {
+ const event = events?.events?.find(
+ (event) => event.id.toString() === item,
+ );
+ field.onChange(event?.id ?? null);
+ }}
+ searchPlaceholder="Select Event"
+ />
+
+ {fieldState.error?.message?.toString()}
+
+ >
+ )}
+ />
+
+ )}
+
+ >
+ );
+};
diff --git a/apps/map/src/app/_components/forms/location-event-form.tsx b/apps/map/src/app/_components/forms/location-event-form.tsx
deleted file mode 100644
index 22635027..00000000
--- a/apps/map/src/app/_components/forms/location-event-form.tsx
+++ /dev/null
@@ -1,576 +0,0 @@
-import lt from "lodash/lt";
-import { X } from "lucide-react";
-import { useMemo } from "react";
-import { Controller } from "react-hook-form";
-import { z } from "zod";
-
-import { DayOfWeek } from "@acme/shared/app/enums";
-import { Case } from "@acme/shared/common/enums";
-import { convertCase, isTruthy } from "@acme/shared/common/functions";
-import { Button } from "@acme/ui/button";
-import { Input } from "@acme/ui/input";
-import { MultiSelect } from "@acme/ui/multi-select";
-import { ControlledSelect } from "@acme/ui/select";
-import { Textarea } from "@acme/ui/textarea";
-import { toast } from "@acme/ui/toast";
-import { RequestInsertSchema } from "@acme/validators";
-
-import { orpc, useQuery } from "~/orpc/react";
-import { useUpdateLocationFormContext } from "~/utils/forms";
-import { scaleAndCropImage } from "~/utils/image/scale-and-crop-image";
-import { uploadLogo } from "~/utils/image/upload-logo";
-import { mapStore } from "~/utils/store/map";
-import { DebouncedImage } from "../debounced-image";
-import { CountrySelect } from "../modal/country-select";
-import { ControlledTimeInput } from "../time-input";
-import { VirtualizedCombobox } from "../virtualized-combobox";
-
-export const UpdateLocationSchema = RequestInsertSchema.extend({
- badImage: z.boolean().default(false),
-});
-
-type UpdateLocationSchema = z.infer;
-
-export const LocationEventForm = ({
- isAdminForm = false,
-}: {
- isAdminForm?: boolean;
-}) => {
- const form = useUpdateLocationFormContext();
- const formId = form.watch("id");
- const formRegionId = form.watch("regionId");
- const formLocationId = form.watch("locationId");
- const formAoId = form.watch("aoId");
- console.log("form eventTypeIds", form.getValues().eventTypeIds);
-
- // Get form values
- const { data: regionsResponse } = useQuery(
- orpc.map.location.regions.queryOptions(),
- );
- const regions = regionsResponse?.regions;
- const { data: allAoData } = useQuery(
- orpc.org.all.queryOptions({ input: { orgTypes: ["ao"] } }),
- );
- const { data: locations } = useQuery(orpc.location.all.queryOptions());
- const { data: eventTypes } = useQuery(
- orpc.eventType.all.queryOptions({
- input: {
- orgIds: formRegionId ? [formRegionId] : [],
- },
- }),
- );
- const aos = useMemo(() => allAoData?.orgs, [allAoData]);
-
- const sortedRegionLocationOptions = useMemo(() => {
- return locations?.locations
- ?.filter((l) => !formRegionId || l.regionId === formRegionId)
- ?.sort((a, b) =>
- a.regionId === formRegionId && b.regionId !== formRegionId
- ? -1
- : a.regionId !== formRegionId && b.regionId === formRegionId
- ? 1
- : a.locationName.localeCompare(b.locationName),
- )
- ?.map((l) => ({
- labelComponent: (
-
- {`${l.locationName}${l.regionName ? ` (${l.regionName})` : ""}`}
- {` ${[l.addressStreet, l.addressStreet2, l.addressCity, l.addressState, l.addressZip, l.addressCountry].filter(isTruthy).join(", ")}`}
-
- ),
- label: `${l.locationName}${l.regionName ? ` (${l.regionName})` : ""} ${[l.addressStreet, l.addressStreet2, l.addressCity, l.addressState, l.addressZip, l.addressCountry].filter(isTruthy).join(", ")}`,
- value: l.id.toString(),
- regionId: l.regionId,
- }));
- }, [locations, formRegionId]);
-
- const sortedRegionAoOptions = useMemo(() => {
- return (
- aos
- ?.filter((a) => !formRegionId || a.parentId === formRegionId)
- ?.map((ao) => ({
- label: `${ao.name} (${ao.parentOrgName})`,
- value: ao.id.toString(),
- }))
- ?.sort((a, b) => a.label.localeCompare(b.label)) ?? []
- );
- }, [aos, formRegionId]);
-
- return (
- <>
-
- Event Details:
-
-
-
-
- Workout Name
-
-
-
- {form.formState.errors.eventName?.message}
-
-
-
-
-
- Day of Week
-
-
({
- value: day,
- label: convertCase({
- str: day,
- fromCase: Case.LowerCase,
- toCase: Case.TitleCase,
- }),
- }))}
- placeholder="Select a day of the week"
- />
-
- {form.formState.errors.eventDayOfWeek?.message}
-
-
-
-
-
-
-
-
-
-
-
-
- Event Types
-
-
{
- console.log("eventTypes", eventTypes, field.value);
- return (
-
-
({
- label: type.name,
- value: type.id.toString(),
- })) ?? []
- }
- onValueChange={(values) =>
- field.onChange(values.map(Number))
- }
- placeholder="Select event types"
- />
- {fieldState.error && (
-
- You must select at least one event type
-
- )}
-
- );
- }}
- />
-
-
-
-
- Event Description
-
-
-
- {form.formState.errors.eventDescription?.message}
-
-
-
-
- Physical Location Details:
-
-
- Location Region
-
-
-
({
- label: region.name,
- value: region.id.toString(),
- }))
- .sort((a, b) => a.label.localeCompare(b.label)) ?? []
- }
- value={formRegionId?.toString()}
- onSelect={(item) => {
- const region = regions?.find(
- (region) => region.id.toString() === item,
- );
- if (region) {
- form.setValue("regionId", region.id);
- }
- }}
- searchPlaceholder="Select"
- />
-
- {form.formState.errors.regionId?.message}
-
-
-
- Existing location
-
-
-
{
- const location = locations?.locations.find(
- ({ id }) => id.toString() === item,
- );
- form.setValue("locationId", location?.id ?? null);
- if (!location) return;
-
- // Handle different property names between components
- form.setValue("locationDescription", location.description ?? "");
- form.setValue("locationAddress", location.addressStreet);
- form.setValue("locationAddress2", location.addressStreet2);
- form.setValue("locationCity", location.addressCity);
- form.setValue("locationState", location.addressState);
- form.setValue("locationZip", location.addressZip);
- form.setValue("locationCountry", location.addressCountry);
-
- // We need to keep the lat lng when the marker has been moved
- if (!isAdminForm) {
- const possiblyUpdatedLocation = mapStore.get(
- "modifiedLocationMarkers",
- )[location.id];
- form.setValue(
- "locationLat",
- possiblyUpdatedLocation?.lat ?? location.latitude,
- );
- form.setValue(
- "locationLng",
- possiblyUpdatedLocation?.lng ?? location.longitude,
- );
- } else {
- form.setValue("locationLat", location.latitude);
- form.setValue("locationLng", location.longitude);
- }
-
- if (location?.regionId == undefined) {
- // @ts-expect-error -- must remove regionId from form
- form.setValue("regionId", null);
- } else {
- form.setValue("regionId", location?.regionId);
- }
- }}
- searchPlaceholder="Select"
- />
-
- Select a location above to move this workout to a different location
-
-
-
- The fields below update the location for all associated workouts
-
-
-
-
- Location Description
-
-
-
- {form.formState.errors.locationDescription?.message}
-
-
-
-
- Location Address
-
-
-
- {form.formState.errors.locationAddress?.message}
-
-
-
-
- Location Address 2
-
-
-
- {form.formState.errors.locationAddress2?.message}
-
-
-
-
- Location City
-
-
-
- {form.formState.errors.locationCity?.message}
-
-
-
-
- Location State
-
-
-
- {form.formState.errors.locationState?.message}
-
-
-
-
- Location Zip
-
-
-
- {form.formState.errors.locationZip?.message}
-
-
-
-
- Location Country
-
-
-
- {form.formState.errors.locationCountry?.message}
-
-
-
-
- Location Latitude
-
-
-
- {form.formState.errors.locationLat?.message?.toString?.()}
-
-
-
-
- Location Longitude
-
-
-
- {form.formState.errors.locationLng?.message?.toString?.()}
-
-
-
-
- AO Details:
-
-
-
-
- Existing AO
-
-
-
{
- const ao = aos?.find((ao) => ao.id.toString() === item);
- if (ao) {
- form.setValue("aoId", ao.id);
- form.setValue("aoName", ao.name);
- form.setValue("aoLogo", ao.logoUrl);
- }
- }}
- searchPlaceholder="Select"
- className="overflow-hidden"
- />
-
- Select an AO here to move this workout to a different AO
-
-
-
- {form.formState.errors.aoId?.message}
-
-
-
-
- AO Name
-
-
-
- {form.formState.errors.aoName?.message}
-
-
-
-
- AO Logo
-
-
{
- return (
-
-
{
- if (formRegionId == null) {
- toast.error("Please select a region first");
- return;
- }
- console.log("files", e.target.files);
- const file = e.target.files?.[0];
- if (!file) return;
-
- const blob640 = await scaleAndCropImage(file, 640, 640);
- if (!blob640) return;
- const url640 = await uploadLogo({
- file: blob640,
- regionId: formRegionId,
- requestId: formId,
- });
- onChange(url640);
- const blob64 = await scaleAndCropImage(file, 64, 64);
- if (blob64) {
- await uploadLogo({
- file: blob64,
- regionId: formRegionId,
- requestId: formId,
- size: 64,
- });
- }
- }}
- disabled={lt(formRegionId, 0)}
- className="flex-1"
- />
- {value && (
-
- )}
-
- );
- }}
- />
-
- {form.formState.errors.aoLogo?.message}
-
-
-
-
- AO Website
-
-
-
- Only add an AO website here
- if it is different than the{" "}
- Region website (edited
- separately). Both show on the map event.
-
-
- {form.formState.errors.aoWebsite?.message}
-
-
-
-
- Other Details:
-
-
-
-
- Submitter Email
-
-
-
- {form.formState.errors.submittedBy?.message}
-
-
-
- >
- );
-};
-
-export const DevLoadTestData = () => {
- const form = useUpdateLocationFormContext();
- return (
-
- );
-};
-
-export const FormDebugData = () => {
- const form = useUpdateLocationFormContext();
- const formId = form.watch("id");
- const formEventId = form.watch("eventId");
- const formAoId = form.watch("aoId");
- const formRegionId = form.watch("regionId");
- const formLocationId = form.watch("locationId");
- return (
-
-
formId: {formId};
-
regionId: {formRegionId};
-
aoId: {formAoId};
-
- locationId: {formLocationId};
-
-
eventId: {formEventId}
-
- );
-};
diff --git a/apps/map/src/app/_components/forms/submit-section.tsx b/apps/map/src/app/_components/forms/submit-section.tsx
new file mode 100644
index 00000000..13f090b9
--- /dev/null
+++ b/apps/map/src/app/_components/forms/submit-section.tsx
@@ -0,0 +1,348 @@
+import { useMemo, useState } from "react";
+import { useRouter } from "next/navigation";
+import { useFormContext } from "react-hook-form";
+
+import { isProductionNodeEnv } from "@acme/shared/common/constants";
+import { isTruthy } from "@acme/shared/common/functions";
+import { cn } from "@acme/ui";
+import { Button } from "@acme/ui/button";
+import { Spinner } from "@acme/ui/spinner";
+import { toast } from "@acme/ui/toast";
+
+import { invalidateQueries, orpc, useMutation, useQuery } from "~/orpc/react";
+import { closeModal } from "~/utils/store/modal";
+import { DevLoadTestData } from "../forms/dev-debug-component";
+import { handleSubmissionError } from "../modal/utils/handle-submission-error";
+
+// ==================== Types ====================
+
+interface SubmitSectionFormValues {
+ id?: string;
+ originalRegionId?: number;
+ newRegionId?: number;
+ isReview?: boolean;
+}
+
+interface MutationResult {
+ status: "pending" | "rejected" | "approved";
+}
+
+interface SubmitSectionProps {
+ mutationFn: (values: T) => Promise;
+ text: string;
+ className?: string;
+}
+
+interface SubmitButtonProps {
+ text: string;
+ spinnerText?: string;
+ isSubmitting: boolean;
+ disabled?: boolean;
+ variant?: "default" | "destructive" | "approve";
+ className?: string;
+ onClick: () => void | Promise;
+}
+
+interface PermissionMessageProps {
+ canEdit: boolean;
+ isReview?: boolean;
+}
+
+// ==================== Custom Hooks ====================
+
+/**
+ * Hook to check if user has edit permissions for regions
+ */
+function useRegionPermissions(params: {
+ originalRegionId?: number;
+ newRegionId?: number;
+}) {
+ const orgIds = useMemo(
+ () => [params.originalRegionId, params.newRegionId].filter(isTruthy),
+ [params.originalRegionId, params.newRegionId],
+ );
+
+ const { data: canEditRegion } = useQuery(
+ orpc.request.canEditRegions.queryOptions({
+ input: { orgIds },
+ enabled: orgIds.length > 0,
+ }),
+ );
+
+ const canEdit = useMemo(
+ () =>
+ Array.isArray(canEditRegion) && canEditRegion.length > 0
+ ? canEditRegion.every((result) => result.success)
+ : false,
+ [canEditRegion],
+ );
+
+ return { canEdit };
+}
+
+/**
+ * Hook to handle mutation result and show appropriate feedback
+ */
+function useRequestStatusHandler() {
+ const router = useRouter();
+
+ const handleMutationResult = async (result: MutationResult) => {
+ switch (result.status) {
+ case "pending":
+ toast.success(
+ "Request submitted. An admin will review your submission soon.",
+ );
+ closeModal();
+ break;
+
+ case "rejected":
+ toast.error("Failed to submit update request");
+ throw new Error("Failed to submit update request");
+
+ case "approved":
+ void invalidateQueries({
+ predicate: (query) => query.queryKey[0] === "request",
+ });
+ toast.success("Update request automatically applied");
+ closeModal();
+ router.refresh();
+ break;
+
+ default:
+ throw new Error("Unknown mutation status");
+ }
+ };
+
+ return { handleMutationResult };
+}
+
+/**
+ * Hook to handle form submission with loading state and error handling
+ */
+function useFormSubmission(params: {
+ mutationFn: (values: T) => Promise;
+ enableLogging?: boolean;
+}) {
+ const { mutateAsync: rejectSubmission } = useMutation(
+ orpc.request.rejectSubmission.mutationOptions(),
+ );
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const form = useFormContext();
+ const { handleMutationResult } = useRequestStatusHandler();
+
+ const submitForm = async () => {
+ setIsSubmitting(true);
+ try {
+ await form.handleSubmit(
+ async (values) => {
+ if (params.enableLogging) {
+ console.log("submit section values", values);
+ }
+ try {
+ const result = await params.mutationFn(values as T);
+ await handleMutationResult(result);
+ } catch (error) {
+ handleSubmissionError(error);
+ }
+ },
+ (errors) => {
+ console.log("Form validation errors:", errors);
+ handleSubmissionError(errors);
+ },
+ )();
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const rejectForm = async () => {
+ const id = form.getValues("id");
+ if (!id) {
+ toast.error("No request ID found");
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ await rejectSubmission({ id });
+ toast.success("Request rejected");
+ closeModal();
+ } catch (error) {
+ handleSubmissionError(error);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return { isSubmitting, submitForm, rejectForm };
+}
+
+// ==================== Components ====================
+
+/**
+ * Reusable submit button with loading state
+ */
+function SubmitButton({
+ text,
+ spinnerText,
+ isSubmitting,
+ disabled,
+ variant = "default",
+ className,
+ onClick,
+}: SubmitButtonProps) {
+ const getButtonStyles = () => {
+ switch (variant) {
+ case "approve":
+ return "w-full bg-green-600 text-white hover:bg-green-600/80 sm:w-auto";
+ case "destructive":
+ return "w-full sm:w-auto";
+ case "default":
+ default:
+ return "w-full bg-blue-600 text-white hover:bg-blue-600/80 sm:w-auto";
+ }
+ };
+
+ return (
+
+ );
+}
+
+/**
+ * Message showing permission status
+ */
+function PermissionMessage({ canEdit, isReview }: PermissionMessageProps) {
+ const getMessage = () => {
+ if (isReview) {
+ return canEdit
+ ? "You can review this request since you have edit permissions for the region."
+ : "You cannot approve or reject this request because you do not have edit permissions for the region.";
+ }
+
+ return canEdit
+ ? "Since you can edit this region, these changes will be reflected immediately"
+ : "Since you are not an editor or admin of this region, this will submit a request for review";
+ };
+
+ return (
+
+ {getMessage()}
+
+ );
+}
+
+/**
+ * Review action buttons (Approve/Reject)
+ */
+function ReviewActions({
+ isSubmitting,
+ canEdit,
+ className,
+ onSubmit,
+ onReject,
+}: {
+ isSubmitting: boolean;
+ canEdit: boolean;
+ className?: string;
+ onSubmit: () => Promise;
+ onReject: () => Promise;
+}) {
+ return (
+
+
+
+
+ );
+}
+
+// ==================== Main Component ====================
+
+/**
+ * Submit section component with permission checking and action buttons
+ */
+export function SubmitSection({
+ mutationFn,
+ text,
+ className,
+}: SubmitSectionProps) {
+ const form = useFormContext();
+ const originalRegionId = form.watch("originalRegionId");
+ const newRegionId = form.watch("newRegionId");
+ const isReview = form.watch("isReview");
+
+ const { canEdit } = useRegionPermissions({
+ originalRegionId,
+ newRegionId,
+ });
+
+ const { isSubmitting, submitForm, rejectForm } = useFormSubmission({
+ mutationFn,
+ enableLogging: !isReview,
+ });
+
+ return (
+
+ {isReview ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {!isProductionNodeEnv &&
}
+
+ );
+}
diff --git a/apps/map/src/app/_components/map/desktop-location-panel-content.tsx b/apps/map/src/app/_components/map/desktop-location-panel-content.tsx
index 57c4a964..b37b931b 100644
--- a/apps/map/src/app/_components/map/desktop-location-panel-content.tsx
+++ b/apps/map/src/app/_components/map/desktop-location-panel-content.tsx
@@ -4,8 +4,15 @@ import { X } from "lucide-react";
import { BreakPoints } from "@acme/shared/app/constants";
import { TestId } from "@acme/shared/common/enums";
+import { orpc, useQuery } from "~/orpc/react";
+import { appStore } from "~/utils/store/app";
import { closePanel, selectedItemStore } from "~/utils/store/selected-item";
import { WorkoutDetailsContent } from "../workout/workout-details-content";
+import {
+ formatTime,
+ getShortDayOfWeek,
+ LocationEditButtons,
+} from "./location-edit-buttons";
export const DesktopLocationPanelContent = () => {
const panelLocationId = selectedItemStore.use.panelLocationId();
@@ -13,32 +20,69 @@ export const DesktopLocationPanelContent = () => {
const width = useWindowWidth();
const isLarge = width > Number(BreakPoints.LG);
const isMedium = width > Number(BreakPoints.MD);
+ const mode = appStore.use.mode();
+
+ // Get location data including events
+ const { data: locationData } = useQuery(
+ orpc.map.location.locationWorkout.queryOptions({
+ input: { locationId: panelLocationId ?? -1 },
+ enabled: panelLocationId !== null,
+ }),
+ );
+
+ // Get AO name and selected event name
+ const aoName = locationData?.location.parentName ?? "AO";
+ const selectedEvent = locationData?.location.events.find(
+ (event) => event.id === panelEventId,
+ );
+ const modalAOIds = locationData?.location.events.map((e) => e.aoId);
+ const aoId = selectedEvent?.aoId ?? modalAOIds?.[0] ?? null;
+ const eventName = selectedEvent?.name ?? "Workout";
+
+ // Get short day of week and format time
+ const shortDayOfWeek = getShortDayOfWeek(selectedEvent?.dayOfWeek);
+ const formattedTime = formatTime(selectedEvent?.startTime);
+ const timeDisplay =
+ shortDayOfWeek && formattedTime ? `${shortDayOfWeek} ${formattedTime}` : "";
if (!panelLocationId) return null;
return (
+ {/* Close button in the top right */}
+
+
+ {/* Edit buttons at the top */}
+ {mode === "edit" && (
+
+
+
+ )}
+
-
-
-
-
-
);
};
diff --git a/apps/map/src/app/_components/map/group-marker.tsx b/apps/map/src/app/_components/map/group-marker.tsx
index ffad62fc..d0d1cb38 100644
--- a/apps/map/src/app/_components/map/group-marker.tsx
+++ b/apps/map/src/app/_components/map/group-marker.tsx
@@ -8,6 +8,7 @@ import {
import { Z_INDEX } from "@acme/shared/app/constants";
import { dayOfWeekToShortDayOfWeek } from "@acme/shared/app/functions";
import { cn } from "@acme/ui";
+import { toast } from "@acme/ui/toast";
import { groupMarkerClick } from "~/utils/actions/group-marker-click";
import { appStore } from "~/utils/store/app";
@@ -89,6 +90,11 @@ export const FeatureMarker = ({
[id]: { lat, lng },
},
});
+
+ toast.success(
+ 'Marker moved! Latitude and longitude updated in draft. Click "Edit AO" → "Edit AO details" and Save to persist these changes to the database.',
+ { duration: 10000 },
+ );
},
[id],
);
diff --git a/apps/map/src/app/_components/map/initial-location-provider.tsx b/apps/map/src/app/_components/map/initial-location-provider.tsx
index 51b4b59f..250d78ef 100644
--- a/apps/map/src/app/_components/map/initial-location-provider.tsx
+++ b/apps/map/src/app/_components/map/initial-location-provider.tsx
@@ -1,7 +1,7 @@
"use client";
import type { ReactNode } from "react";
-import { createContext, Suspense, useContext, useRef } from "react";
+import { createContext, Suspense, useContext, useEffect, useRef } from "react";
import { useSearchParams } from "next/navigation";
import {
@@ -51,8 +51,9 @@ const SuspendedInitialLocationProvider = (params: { children: ReactNode }) => {
const center = useRef(null);
const zoom = useRef(null);
- const hasAttemptedSetInitialSelectedItem = useRef(false);
+ const hasInitialized = useRef(false);
+ // Calculate initial values during render (reading is safe)
if (center.current === null) {
const locationLatLng = getQueryData(
orpc.map.location.eventsAndLocations.queryKey({
@@ -62,28 +63,18 @@ const SuspendedInitialLocationProvider = (params: { children: ReactNode }) => {
const locLat = locationLatLng?.[3];
const locLon = locationLatLng?.[4];
- center.current =
+ const calculatedCenter =
locLat != null && locLon != null
? { lat: locLat, lng: locLon }
: queryLat != null && queryLon != null
? { lat: queryLat, lng: queryLon }
: null;
- mapStore.setState({
- didSetQueryParamLocation: !!center.current,
- });
- center.current ??= mapStore.get("center") ?? {
- lat: DEFAULT_CENTER[0],
- lng: DEFAULT_CENTER[1],
- };
-
- mapStore.setState({
- nearbyLocationCenter: {
- ...center.current,
- name: "",
- type: "default",
- },
- });
+ center.current = calculatedCenter ??
+ mapStore.get("center") ?? {
+ lat: DEFAULT_CENTER[0],
+ lng: DEFAULT_CENTER[1],
+ };
}
if (zoom.current === null) {
@@ -97,8 +88,46 @@ const SuspendedInitialLocationProvider = (params: { children: ReactNode }) => {
mapStore.get("zoom") ?? DEFAULT_ZOOM;
}
- if (!hasAttemptedSetInitialSelectedItem.current) {
- hasAttemptedSetInitialSelectedItem.current = true;
+ // Perform state updates in useEffect (not during render)
+ useEffect(() => {
+ if (hasInitialized.current) return;
+ hasInitialized.current = true;
+
+ const locationLatLng = getQueryData(
+ orpc.map.location.eventsAndLocations.queryKey({
+ input: undefined,
+ }),
+ )?.find((location) => location[0] === queryLocationId);
+ const locLat = locationLatLng?.[3];
+ const locLon = locationLatLng?.[4];
+
+ const calculatedCenter =
+ locLat != null && locLon != null
+ ? { lat: locLat, lng: locLon }
+ : queryLat != null && queryLon != null
+ ? { lat: queryLat, lng: queryLon }
+ : null;
+
+ const didSetQueryParamLocation = !!calculatedCenter;
+
+ mapStore.setState({
+ didSetQueryParamLocation,
+ });
+
+ const finalCenter = calculatedCenter ??
+ mapStore.get("center") ?? {
+ lat: DEFAULT_CENTER[0],
+ lng: DEFAULT_CENTER[1],
+ };
+
+ mapStore.setState({
+ nearbyLocationCenter: {
+ ...finalCenter,
+ name: "",
+ type: "default",
+ },
+ });
+
if (queryLocationId != null) {
setSelectedItem({
locationId: queryLocationId,
@@ -106,7 +135,7 @@ const SuspendedInitialLocationProvider = (params: { children: ReactNode }) => {
showPanel: true,
});
}
- }
+ }, [queryLocationId, queryEventId, queryLat, queryLon]);
return (
• Click on a workout to see edit options
• Drag markers to move workouts to new locations
- • Click on the map (and then again on the new marker) to add a new
+ • Click on the map to place a pin and see options for adding a new
location
diff --git a/apps/map/src/app/_components/map/location-edit-buttons.tsx b/apps/map/src/app/_components/map/location-edit-buttons.tsx
new file mode 100644
index 00000000..da35261e
--- /dev/null
+++ b/apps/map/src/app/_components/map/location-edit-buttons.tsx
@@ -0,0 +1,306 @@
+import { ArrowRight, CirclePlus, Edit, Trash } from "lucide-react";
+
+import { Button } from "@acme/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@acme/ui/dropdown-menu";
+
+import { orpc, useQuery } from "~/orpc/react";
+import { openRequestModal } from "~/utils/open-request-modal";
+import { appStore } from "~/utils/store/app";
+import { useFilteredMapResults } from "./filtered-map-results-provider";
+
+interface LocationEditButtonsProps {
+ locationId: number;
+ eventId?: number | null;
+ aoId?: number | null;
+ aoName?: string;
+ eventName?: string;
+ timeDisplay?: string;
+ eventCount?: number;
+}
+
+export const LocationEditButtons = ({
+ locationId,
+ eventId,
+ aoId,
+ aoName = "AO",
+ eventName = "Workout",
+ timeDisplay = "",
+ eventCount = 0,
+}: LocationEditButtonsProps) => {
+ const mode = appStore.use.mode();
+ const { locationOrderedLocationMarkers } = useFilteredMapResults();
+
+ const { data: workoutInfo } = useQuery(
+ orpc.map.location.locationWorkout.queryOptions({
+ input: { locationId: locationOrderedLocationMarkers?.[0]?.id ?? -1 },
+ enabled: mode === "edit" && !!locationOrderedLocationMarkers?.[0]?.id,
+ }),
+ );
+
+ return (
+
+ {/* Add Event Button */}
+
+
+
+ {/* Event Edit Button - only show if an event is selected */}
+ {eventId && (
+
+
+
+
+
+ {
+ // Need locationId to query the event
+ void openRequestModal({
+ type: "edit_event",
+ eventId,
+ locationId,
+ });
+ }}
+ >
+
+
+
+ Edit workout details
+
+
+
+
+ {
+ void openRequestModal({
+ type: "move_event_to_different_ao",
+ locationId,
+ eventId,
+ aoId,
+ meta: {
+ newRegionId: workoutInfo?.location.regionId ?? undefined,
+ newAoId:
+ workoutInfo?.location.events[0]?.aoId ?? undefined,
+ },
+ });
+ }}
+ >
+
+
+
+ Move to different AO
+
+
+
+
+ {
+ void openRequestModal({
+ type: "move_event_to_new_ao",
+ locationId,
+ eventId,
+ aoId,
+ meta: {
+ newRegionId: workoutInfo?.location.regionId ?? undefined,
+ newAoId:
+ workoutInfo?.location.events[0]?.aoId ?? undefined,
+ },
+ });
+ }}
+ >
+
+
+
+ Move to a new AO
+
+
+
+
+
+
+ {
+ void openRequestModal({
+ type: "delete_event",
+ locationId,
+ eventId,
+ aoId,
+ });
+ }}
+ >
+
+
+
+ Delete this workout
+
+
+
+
+
+ )}
+
+ {/* AO Edit Button */}
+
+
+
+
+ {
+ void openRequestModal({
+ type: "edit_ao_and_location",
+ locationId,
+ eventId,
+ aoId,
+ });
+ }}
+ >
+
+
+
+ Edit AO details
+
+
+
+
+ {
+ void openRequestModal({
+ type: "move_ao_to_different_location",
+ locationId,
+ eventId,
+ aoId,
+ });
+ }}
+ >
+
+
+
+ Move AO to different location
+
+
+
+
+ {
+ void openRequestModal({
+ type: "move_ao_to_different_region",
+ locationId,
+ eventId,
+ aoId,
+ meta: {
+ newRegionId: workoutInfo?.location.regionId ?? undefined,
+ },
+ });
+ }}
+ >
+
+
+
+ Move AO to different region
+
+
+
+
+
+
+ {
+ void openRequestModal({ type: "delete_ao", locationId, aoId });
+ }}
+ >
+
+
+
+ Delete this AO
+
+
+
+
+
+
+ );
+};
+
+export const getShortDayOfWeek = (day: string | null | undefined) => {
+ if (!day) return "";
+
+ switch (day.toLowerCase()) {
+ case "monday":
+ return "M";
+ case "tuesday":
+ return "Tu";
+ case "wednesday":
+ return "W";
+ case "thursday":
+ return "Th";
+ case "friday":
+ return "F";
+ case "saturday":
+ return "Sa";
+ case "sunday":
+ return "Su";
+ default:
+ return "";
+ }
+};
+
+export const formatTime = (time: string | null | undefined) => {
+ if (!time || time.length !== 4) return "";
+
+ const hour = parseInt(time.substring(0, 2));
+ const minute = time.substring(2, 4);
+ const period = hour >= 12 ? "p" : "a";
+ const displayHour = hour > 12 ? hour - 12 : hour === 0 ? 12 : hour;
+
+ return `${displayHour}:${minute}${period}`;
+};
diff --git a/apps/map/src/app/_components/map/update-pane.tsx b/apps/map/src/app/_components/map/update-pane.tsx
index e3bf3e2a..1e1c9de0 100644
--- a/apps/map/src/app/_components/map/update-pane.tsx
+++ b/apps/map/src/app/_components/map/update-pane.tsx
@@ -1,56 +1,158 @@
import { AdvancedMarker } from "@vis.gl/react-google-maps";
-import { MapPinPlusInside } from "lucide-react";
+import { ArrowDownToDot, MapPin, MapPinPlusInside, X } from "lucide-react";
import { Z_INDEX } from "@acme/shared/app/constants";
import { TestId } from "@acme/shared/common/enums";
+import { Button } from "@acme/ui/button";
+import { toast } from "@acme/ui/toast";
+import { orpc, useQuery } from "~/orpc/react";
+import { openRequestModal } from "~/utils/open-request-modal";
import { appStore } from "~/utils/store/app";
import { mapStore } from "~/utils/store/map";
-import {
- eventDefaults,
- locationDefaults,
- ModalType,
- openModal,
-} from "~/utils/store/modal";
+import { useFilteredMapResults } from "./filtered-map-results-provider";
export const UpdatePane = () => {
const updateLocation = mapStore.use.updateLocation();
+ const { locationOrderedLocationMarkers } = useFilteredMapResults();
const mode = appStore.use.mode();
+ const { data: workoutInfo } = useQuery(
+ orpc.map.location.locationWorkout.queryOptions({
+ input: { locationId: locationOrderedLocationMarkers?.[0]?.id ?? -1 },
+ enabled: mode === "edit" && !!locationOrderedLocationMarkers?.[0]?.id,
+ }),
+ );
+
+ // Function to clear the update location pin
+ const clearUpdateLocation = (e: { stopPropagation: () => void }) => {
+ e.stopPropagation();
+ mapStore.setState({ updateLocation: null });
+ };
+
+ // Create new location with new AO and event
+ const handleCreateNew = async () => {
+ if (!updateLocation) {
+ toast.error("New location marker not found");
+ return;
+ }
+
+ void openRequestModal({
+ type: "create_ao_and_location_and_event",
+ meta: { originalRegionId: workoutInfo?.location.regionId ?? undefined },
+ });
+ };
+
+ // Move existing AO to this location
+ const handleMoveAO = () => {
+ if (!updateLocation) {
+ toast.error("New location marker not found");
+ return;
+ }
+
+ void openRequestModal({
+ type: "move_ao_to_new_location",
+ meta: {
+ originalRegionId: workoutInfo?.location.regionId ?? undefined,
+ originalAoId: workoutInfo?.location.events[0]?.aoId ?? undefined,
+ originalLocationId: workoutInfo?.location.id ?? undefined,
+ },
+ });
+ };
+
+ // Move existing event to new AO here
+ const handleMoveEvent = () => {
+ if (!updateLocation) {
+ toast.error("New location marker not found");
+ return;
+ }
+
+ void openRequestModal({
+ type: "move_event_to_new_location",
+ meta: {
+ originalRegionId: workoutInfo?.location.regionId ?? undefined,
+ originalAoId: workoutInfo?.location.events[0]?.aoId ?? undefined,
+ originalLocationId: workoutInfo?.location.id ?? undefined,
+ },
+ });
+ };
+
+ if (!updateLocation || mode !== "edit") return null;
+
return (
-
- {updateLocation && mode === "edit" ? (
- <>
-
{
- if (!e.latLng) throw new Error("No latLng");
- openModal(ModalType.UPDATE_LOCATION, {
- requestType: "create_location",
- ...eventDefaults,
- ...locationDefaults,
- lat: e.latLng.lat(),
- lng: e.latLng.lng(),
- });
- }}
- onDragEnd={(e) => {
- if (!e.latLng) throw new Error("No latLng");
- mapStore.setState({
- updateLocation: {
- lat: e.latLng.lat(),
- lng: e.latLng.lng(),
- },
- });
- }}
- position={updateLocation}
- >
-
-
- >
- ) : null}
-
+ {
+ if (!e.latLng) throw new Error("No latLng");
+ mapStore.setState({
+ updateLocation: {
+ lat: e.latLng.lat(),
+ lng: e.latLng.lng(),
+ },
+ });
+ }}
+ position={updateLocation}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
};
diff --git a/apps/map/src/app/_components/marker-clusters/clustered-markers.tsx b/apps/map/src/app/_components/marker-clusters/clustered-markers.tsx
index 7218d5c1..314ebd3e 100644
--- a/apps/map/src/app/_components/marker-clusters/clustered-markers.tsx
+++ b/apps/map/src/app/_components/marker-clusters/clustered-markers.tsx
@@ -1,7 +1,8 @@
import type Supercluster from "supercluster";
import { useCallback, useMemo } from "react";
import { useMap } from "@vis.gl/react-google-maps";
-import { CLOSE_ZOOM } from "node_modules/@acme/shared/src/app/constants";
+
+import { CLOSE_ZOOM } from "@acme/shared/app/constants";
import type {
F3ClusterProperties,
diff --git a/apps/map/src/app/_components/modal/admin-delete-request-modal.tsx b/apps/map/src/app/_components/modal/admin-delete-request-modal.tsx
deleted file mode 100644
index 5fa5c24e..00000000
--- a/apps/map/src/app/_components/modal/admin-delete-request-modal.tsx
+++ /dev/null
@@ -1,209 +0,0 @@
-"use client";
-
-import { Trash } from "lucide-react";
-import { useRouter } from "next/navigation";
-import { useState } from "react";
-
-import { Z_INDEX } from "@acme/shared/app/constants";
-import { Button } from "@acme/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
-} from "@acme/ui/dialog";
-import { Spinner } from "@acme/ui/spinner";
-import { toast } from "@acme/ui/toast";
-
-import { ORPCError, invalidateQueries, orpc, useQuery } from "~/orpc/react";
-import type { DataType, ModalType } from "~/utils/store/modal";
-import { closeModal, modalStore } from "~/utils/store/modal";
-
-export default function AdminDeleteRequestModal({
- data: requestData,
-}: {
- data: DataType[ModalType.ADMIN_DELETE_REQUEST];
-}) {
- const { data: regions } = useQuery(
- orpc.org.all.queryOptions({ input: { orgTypes: ["region"] } }),
- );
- const router = useRouter();
- const [status, setStatus] = useState<"approving" | "rejecting" | "idle">(
- "idle",
- );
- const { data: requestResponse } = useQuery(
- orpc.request.byId.queryOptions({ input: { id: requestData.id } }),
- );
- const request = requestResponse?.request;
-
- const onReject = async () => {
- setStatus("rejecting");
- try {
- await orpc.request.rejectSubmission.call({
- id: requestData.id,
- });
- void invalidateQueries({
- predicate: (query) => query.queryKey[0] === "request",
- });
- router.refresh();
- toast.error("Rejected delete request");
- closeModal();
- } catch (error) {
- if (error instanceof ORPCError) {
- toast.error(error.message);
- } else {
- toast.error("Failed to reject delete request");
- }
- console.error(error);
- } finally {
- setStatus("idle");
- }
- };
-
- const onDelete = async () => {
- setStatus("approving");
- if (!request) {
- toast.error("Request not found");
- setStatus("idle");
- return;
- } else if (request.eventId == undefined || request.regionId == undefined) {
- toast.error("Request is missing eventId or regionId");
- setStatus("idle");
- return;
- } else if (request.requestType !== "delete_event") {
- toast.error("Request is not a delete workout request");
- setStatus("idle");
- return;
- }
-
- try {
- await orpc.request.validateDeleteByAdmin.call({
- eventId: request.eventId,
- eventName: request.eventName,
- regionId: request.regionId,
- submittedBy: request.submittedBy,
- });
-
- void invalidateQueries(orpc.request.all.queryOptions());
- router.refresh();
- toast.success("Delete request submitted");
- modalStore.setState({ modals: [] });
- } catch (error) {
- console.error(error);
- if (error instanceof ORPCError) {
- toast.error(error.message);
- } else {
- toast.error("Failed to submit delete request");
- }
- console.error(error);
- } finally {
- setStatus("idle");
- }
- };
-
- if (!request) return Loading...
;
-
- return (
-
- );
-}
diff --git a/apps/map/src/app/_components/modal/admin-requests-modal.tsx b/apps/map/src/app/_components/modal/admin-requests-modal.tsx
deleted file mode 100644
index c6425994..00000000
--- a/apps/map/src/app/_components/modal/admin-requests-modal.tsx
+++ /dev/null
@@ -1,224 +0,0 @@
-"use client";
-
-import { useRouter } from "next/navigation";
-import { useEffect, useState } from "react";
-import { v4 as uuid } from "uuid";
-
-import { Z_INDEX } from "@acme/shared/app/constants";
-import {
- convertHH_mmToHHmm,
- convertHHmmToHH_mm,
-} from "@acme/shared/app/functions";
-import { isProd } from "@acme/shared/common/constants";
-import { Button } from "@acme/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
-} from "@acme/ui/dialog";
-import { Form } from "@acme/ui/form";
-import { Spinner } from "@acme/ui/spinner";
-import { toast } from "@acme/ui/toast";
-
-import {
- ORPCError,
- invalidateQueries,
- orpc,
- useMutation,
- useQuery,
-} from "~/orpc/react";
-import { useUpdateLocationForm } from "~/utils/forms";
-import type { DataType, ModalType } from "~/utils/store/modal";
-import { closeModal } from "~/utils/store/modal";
-import { FormDebugData, LocationEventForm } from "../forms/location-event-form";
-export default function AdminRequestsModal({
- data: requestData,
-}: {
- data: DataType[ModalType.ADMIN_REQUESTS];
-}) {
- const router = useRouter();
- const [status, setStatus] = useState<"approving" | "rejecting" | "idle">(
- "idle",
- );
- const { data: requestResponse } = useQuery(
- orpc.request.byId.queryOptions({
- input: { id: requestData.id },
- enabled: !!requestData.id,
- }),
- );
- const request = requestResponse?.request;
- const form = useUpdateLocationForm({
- defaultValues: { id: request?.id ?? uuid() },
- });
-
- const formId = form.watch("id");
-
- const { data: eventTypes } = useQuery(
- orpc.eventType.all.queryOptions({ input: undefined }),
- );
-
- const validateSubmissionByAdmin = useMutation(
- orpc.request.validateSubmissionByAdmin.mutationOptions(),
- );
- const rejectSubmissionByAdmin = useMutation(
- orpc.request.rejectSubmission.mutationOptions(),
- );
-
- const onSubmit = form.handleSubmit(
- async (values) => {
- try {
- setStatus("approving");
- await validateSubmissionByAdmin.mutateAsync({
- ...values,
- eventStartTime: convertHH_mmToHHmm(values.eventStartTime ?? ""),
- eventEndTime: convertHH_mmToHHmm(values.eventEndTime ?? ""),
- });
- void invalidateQueries({
- predicate: (query) =>
- query.queryKey[0] === "request" ||
- query.queryKey[0] === "event" ||
- query.queryKey[0] === "location",
- });
- router.refresh();
- toast.success("Approved update");
- closeModal();
- } catch (error) {
- console.log(error);
- if (!(error instanceof ORPCError)) {
- toast.error("Failed to approve update");
- return;
- }
-
- if (error.message.includes("End time must be after start time")) {
- form.setError("eventEndTime", {
- message: "End time must be after start time",
- });
- throw new Error("End time must be after start time");
- } else {
- toast.error("Failed to approve update");
- }
- } finally {
- setStatus("idle");
- }
- },
- (error) => {
- toast.error("Failed to approve update");
- console.log(error);
- },
- );
-
- const onReject = async () => {
- setStatus("rejecting");
- console.log("rejecting");
- await rejectSubmissionByAdmin
- .mutateAsync({
- id: formId,
- })
- .then(() => {
- void invalidateQueries({
- predicate: (query) => query.queryKey[0] === "request",
- });
- router.refresh();
- setStatus("idle");
- toast.error("Rejected update");
- closeModal();
- });
- };
-
- useEffect(() => {
- if (!request) return;
- form.reset({
- id: request.id,
- requestType: request.requestType,
- eventId: request.eventId ?? null,
- locationId: request.locationId ?? null,
- eventName: request.eventName ?? "",
- // workoutWebsite: request.web ?? "",
- locationAddress: request.locationAddress ?? "",
- locationAddress2: request.locationAddress2 ?? "",
- locationCity: request.locationCity ?? "",
- locationState: request.locationState ?? "",
- locationZip: request.locationZip ?? "",
- locationCountry: request.locationCountry ?? "",
- locationLat: request.locationLat ?? 0,
- locationLng: request.locationLng ?? 0,
- locationDescription: request.locationDescription ?? "",
- eventStartTime: convertHHmmToHH_mm(request.eventStartTime ?? ""),
- eventEndTime: convertHHmmToHH_mm(request.eventEndTime ?? ""),
- eventDayOfWeek: request.eventDayOfWeek ?? "monday",
- eventTypeIds: request.eventTypeIds ?? [],
- eventDescription: request.eventDescription ?? "",
- regionId: request.regionId ?? null,
- aoId: request.aoId ?? null,
- aoName: request.aoName ?? "",
- aoLogo: request.aoLogo ?? "",
- aoWebsite: request.aoWebsite ?? "",
- submittedBy: request.submittedBy ?? "",
- });
- }, [request, form, eventTypes]);
-
- return (
-
- );
-}
diff --git a/apps/map/src/app/_components/modal/admin-workouts-modal.tsx b/apps/map/src/app/_components/modal/admin-workouts-modal.tsx
index c35cc510..f7bb5619 100644
--- a/apps/map/src/app/_components/modal/admin-workouts-modal.tsx
+++ b/apps/map/src/app/_components/modal/admin-workouts-modal.tsx
@@ -63,10 +63,10 @@ import { ControlledTimeInput } from "../time-input";
import { VirtualizedCombobox } from "../virtualized-combobox";
const EventInsertForm = EventInsertSchema.extend({
- startTime: z.string().regex(/^\d{2}:\d{2}$/, {
+ startTime: z.string().refine((val) => !val || /^\d{2}:\d{2}$/.test(val), {
message: "Start time must be in 24hr format (HH:mm)",
}),
- endTime: z.string().regex(/^\d{2}:\d{2}$/, {
+ endTime: z.string().refine((val) => !val || /^\d{2}:\d{2}$/.test(val), {
message: "End time must be in 24hr format (HH:mm)",
}),
eventTypeIds: z
diff --git a/apps/map/src/app/_components/modal/base-modal.tsx b/apps/map/src/app/_components/modal/base-modal.tsx
new file mode 100644
index 00000000..e7536cc1
--- /dev/null
+++ b/apps/map/src/app/_components/modal/base-modal.tsx
@@ -0,0 +1,40 @@
+import { Z_INDEX } from "@acme/shared/app/constants";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@acme/ui/dialog";
+
+import { closeModal } from "~/utils/store/modal";
+
+export const BaseModal = ({
+ children,
+ title,
+}: {
+ children: React.ReactNode;
+ title?: React.ReactNode;
+}) => {
+ return (
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/country-select.tsx b/apps/map/src/app/_components/modal/country-select.tsx
index 0767d1b9..03d8bcc2 100644
--- a/apps/map/src/app/_components/modal/country-select.tsx
+++ b/apps/map/src/app/_components/modal/country-select.tsx
@@ -29,7 +29,9 @@ export const CountrySelect = ({
disabled = false,
}: CountrySelectProps) => {
const sortedCountries = useMemo(() => {
- return [...COUNTRIES].sort((a, b) => a.name.localeCompare(b.name));
+ return [...COUNTRIES].sort((a, b) =>
+ a.code === "US" ? -1 : b.code === "US" ? 1 : a.name.localeCompare(b.name),
+ );
}, []);
return (
diff --git a/apps/map/src/app/_components/modal/delete-modal.tsx b/apps/map/src/app/_components/modal/delete-modal.tsx
deleted file mode 100644
index 3cea10d6..00000000
--- a/apps/map/src/app/_components/modal/delete-modal.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-"use client";
-
-import { Z_INDEX } from "@acme/shared/app/constants";
-import { cn } from "@acme/ui";
-import { Button } from "@acme/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
-} from "@acme/ui/dialog";
-
-import type { DataType, ModalType } from "~/utils/store/modal";
-import { closeModal } from "~/utils/store/modal";
-
-export default function DeleteModal({
- data,
-}: {
- data: DataType[ModalType.DELETE_CONFIRMATION];
-}) {
- return (
-
- );
-}
diff --git a/apps/map/src/app/_components/modal/loading-modal.tsx b/apps/map/src/app/_components/modal/loading-modal.tsx
new file mode 100644
index 00000000..8a6b61b5
--- /dev/null
+++ b/apps/map/src/app/_components/modal/loading-modal.tsx
@@ -0,0 +1,31 @@
+import { Z_INDEX } from "@acme/shared/app/constants";
+import { Dialog, DialogContent } from "@acme/ui/dialog";
+import { Loader } from "@acme/ui/loader";
+
+import { closeModal } from "~/utils/store/modal";
+
+/**
+ * Loading placeholder modal shown while modal data is being prepared
+ * Single Responsibility: Display loading state for modals
+ */
+export function LoadingModal() {
+ return (
+
+ );
+}
diff --git a/apps/map/src/app/_components/modal/modal-switcher.tsx b/apps/map/src/app/_components/modal/modal-switcher.tsx
index 01ea5b1b..50123abc 100644
--- a/apps/map/src/app/_components/modal/modal-switcher.tsx
+++ b/apps/map/src/app/_components/modal/modal-switcher.tsx
@@ -12,25 +12,35 @@ import AdminAOsModal from "./admin-aos-modal";
import AdminApiKeysModal from "./admin-api-keys-modal";
import AdminAreasModal from "./admin-areas-modal";
import AdminDeleteModal from "./admin-delete-modal";
-import AdminDeleteRequestModal from "./admin-delete-request-modal";
import AdminEventTypesModal from "./admin-event-types-modal";
import AdminGrantAccessModal from "./admin-grant-access-modal";
import AdminLocationsModal from "./admin-locations-modal";
import AdminNationsModal from "./admin-nations-modal";
import AdminRegionsModal from "./admin-regions-modal";
-import AdminRequestsModal from "./admin-requests-modal";
import AdminSectorsModal from "./admin-sectors-modal";
import AdminUsersModal from "./admin-users-modal";
import AdminWorkoutsModal from "./admin-workouts-modal";
-import DeleteModal from "./delete-modal";
+import DeleteConfirmationModal from "./delete-confirmation-modal";
import { EditModeInfoModal } from "./edit-mode-info-modal";
import { FullImageModal } from "./full-image-modal";
import HowToJoinModal from "./how-to-join-modal";
+import { LoadingModal } from "./loading-modal";
import { MapInfoModal } from "./map-info-modal";
import { QRCodeModal } from "./qr-code-modal";
import SettingsModal from "./settings-modal";
import SignInModal from "./sign-in-modal";
-import { UpdateLocationModal } from "./update-location-modal";
+import { CreateAOAndLocationAndEventModal } from "./update/create-ao-and-location-and-event-modal";
+import { CreateEventModal } from "./update/create-event-modal";
+import { DeleteAoModal } from "./update/delete-ao-modal";
+import { DeleteEventModal } from "./update/delete-event-modal";
+import { EditAoAndLocationModal } from "./update/edit-ao-and-location-modal";
+import { EditEventModal } from "./update/edit-event-modal";
+import { MoveAOToDifferentLocationModal } from "./update/move-ao-to-different-location-modal";
+import { MoveAOToDifferentRegionModal } from "./update/move-ao-to-different-region-modal";
+import { MoveAOToNewLocationModal } from "./update/move-ao-to-new-location-modal";
+import { MoveEventToDifferentAoModal } from "./update/move-event-to-different-ao-modal";
+import { MoveEventToNewAoModal } from "./update/move-event-to-new-ao-modal";
+import { MoveEventToNewLocationModal } from "./update/move-event-to-new-location-modal";
import UserLocationInfoModal from "./user-location-info-modal";
import { WorkoutDetailsModal } from "./workout-details-modal";
@@ -46,10 +56,58 @@ export const ModalSwitcher = () => {
return ;
case ModalType.USER_LOCATION_INFO:
return ;
- case ModalType.UPDATE_LOCATION:
+ case ModalType.EDIT_AO_AND_LOCATION:
return (
-
+ );
+ case ModalType.EDIT_EVENT:
+ return ;
+ case ModalType.CREATE_EVENT:
+ return (
+
+ );
+ case ModalType.CREATE_AO_AND_LOCATION_AND_EVENT:
+ return (
+
+ );
+ case ModalType.MOVE_AO_TO_NEW_LOCATION:
+ return (
+
+ );
+ case ModalType.MOVE_EVENT_TO_NEW_LOCATION:
+ return (
+
+ );
+ case ModalType.MOVE_AO_TO_DIFFERENT_LOCATION:
+ return (
+
+ );
+ case ModalType.MOVE_AO_TO_DIFFERENT_REGION:
+ return (
+
+ );
+ case ModalType.MOVE_EVENT_TO_DIFFERENT_AO:
+ return (
+
+ );
+ case ModalType.MOVE_EVENT_TO_NEW_AO:
+ return (
+
);
case ModalType.WORKOUT_DETAILS:
@@ -71,10 +129,6 @@ export const ModalSwitcher = () => {
data={data as DataType[ModalType.ADMIN_GRANT_ACCESS]}
/>
);
- case ModalType.ADMIN_REQUESTS:
- return (
-
- );
case ModalType.ADMIN_EVENTS:
return (
@@ -117,14 +171,16 @@ export const ModalSwitcher = () => {
);
case ModalType.DELETE_CONFIRMATION:
return (
-
+
);
- case ModalType.ADMIN_DELETE_REQUEST:
+ case ModalType.DELETE_EVENT:
return (
-
+
);
+ case ModalType.DELETE_AO:
+ return ;
case ModalType.QR_CODE:
return ;
case ModalType.ABOUT_MAP:
@@ -137,6 +193,8 @@ export const ModalSwitcher = () => {
return ;
case ModalType.EDIT_MODE_INFO:
return ;
+ case ModalType.LOADING:
+ return ;
default:
console.error(`Modal type ${type} not found`);
return null;
diff --git a/apps/map/src/app/_components/modal/update-location-modal.tsx b/apps/map/src/app/_components/modal/update-location-modal.tsx
deleted file mode 100644
index 98510ed1..00000000
--- a/apps/map/src/app/_components/modal/update-location-modal.tsx
+++ /dev/null
@@ -1,267 +0,0 @@
-/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
-import gte from "lodash/gte";
-import { useSession } from "next-auth/react";
-import { useRouter } from "next/navigation";
-import { useEffect, useState } from "react";
-import { v4 as uuid } from "uuid";
-
-import { Z_INDEX } from "@acme/shared/app/constants";
-import {
- convertHH_mmToHHmm,
- convertHHmmToHH_mm,
-} from "@acme/shared/app/functions";
-import { isProd } from "@acme/shared/common/constants";
-import { Button } from "@acme/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
-} from "@acme/ui/dialog";
-import { Form } from "@acme/ui/form";
-import { Spinner } from "@acme/ui/spinner";
-import { toast } from "@acme/ui/toast";
-
-import {
- ORPCError,
- invalidateQueries,
- orpc,
- useMutation,
- useQuery,
-} from "~/orpc/react";
-import { useUpdateLocationForm } from "~/utils/forms";
-import { appStore } from "~/utils/store/app";
-import type { DataType, ModalType } from "~/utils/store/modal";
-import { closeModal } from "~/utils/store/modal";
-import {
- DevLoadTestData,
- FormDebugData,
- LocationEventForm,
-} from "../forms/location-event-form";
-
-export const UpdateLocationModal = ({
- data,
-}: {
- data: DataType[ModalType.UPDATE_LOCATION];
-}) => {
- const router = useRouter();
- const [isSubmitting, setIsSubmitting] = useState(false);
-
- const { mutateAsync: submitUpdateRequest } = useMutation(
- orpc.request.submitUpdateRequest.mutationOptions(),
- );
-
- const form = useUpdateLocationForm({
- defaultValues: {
- locationCountry: "United States",
- },
- mode: "onBlur",
- });
-
- const formRegionId = form.watch("regionId");
- const { data: canEditRegionResponse } = useQuery(
- orpc.request.canEditRegions.queryOptions({
- input: { orgIds: [formRegionId] },
- enabled: !!formRegionId && formRegionId !== -1,
- }),
- );
- const canEditRegion = canEditRegionResponse?.results;
-
- const { data: session } = useSession();
- const { data: eventTypes } = useQuery(
- orpc.eventType.all.queryOptions({ input: undefined }),
- );
-
- const onSubmit = form.handleSubmit(
- async (values) => {
- try {
- console.log("onSubmit values", values);
- setIsSubmitting(true);
- if (values.badImage && !!values.aoLogo) {
- form.setError("aoLogo", { message: "Invalid image URL" });
- throw new Error("Invalid image URL");
- }
- appStore.setState({ myEmail: values.submittedBy });
-
- const updateRequestData = {
- ...values,
- eventStartTime: convertHH_mmToHHmm(values.eventStartTime ?? ""),
- eventEndTime: convertHH_mmToHHmm(values.eventEndTime ?? ""),
- eventId: gte(data.eventId, 0) ? data.eventId ?? null : null,
- };
-
- const result = await submitUpdateRequest(updateRequestData);
- if (result.status === "pending") {
- toast.success(
- "Request submitted. An admin will review your submission soon.",
- );
- } else if (result.status === "rejected") {
- toast.error("Failed to submit update request");
- throw new Error("Failed to submit update request");
- } else if (result.status === "approved") {
- void invalidateQueries({
- predicate: () => true,
- });
- toast.success("Update request automatically applied");
- router.refresh();
- }
-
- closeModal();
- } catch (error) {
- console.error(error);
- if (!(error instanceof Error)) {
- toast.error("Failed to submit update request");
- return;
- }
-
- if (!(error instanceof ORPCError)) {
- toast.error(error.message);
- return;
- }
-
- if (error.message.includes("End time must be after start time")) {
- form.setError("eventEndTime", {
- message: "End time must be after start time",
- });
- toast.error("End time must be after start time");
- throw new Error("End time must be after start time");
- } else {
- toast.error("Failed to submit update request");
- }
- } finally {
- setIsSubmitting(false);
- }
- },
- (errors) => {
- // Get all error messages
- const errorMessages = Object.entries(errors as { message: string }[])
- .map(([field, error]) => {
- if (error?.message) {
- return `${field}: ${error.message}`;
- }
- return null;
- })
- .filter(Boolean);
-
- // Show a toast with the first error message, or a generic message if none found
- toast.error(
-
- {errorMessages.map((error) => (
-
{error}
- ))}
-
,
- );
- console.log("Form validation errors:", errors);
- },
- );
-
- useEffect(() => {
- form.setValue("id", uuid());
- form.setValue("requestType", data.requestType);
- if (data.regionId != null && data.regionId !== -1) {
- form.setValue("regionId", data.regionId);
- } else {
- // @ts-expect-error -- must remove regionId from form
- form.setValue("regionId", null);
- }
-
- form.setValue("locationId", data.locationId ?? null);
- form.setValue("locationAddress", data.locationAddress ?? "");
- form.setValue("locationAddress2", data.locationAddress2 ?? "");
- form.setValue("locationCity", data.locationCity ?? "");
- form.setValue("locationState", data.locationState ?? "");
- form.setValue("locationZip", data.locationZip ?? "");
- form.setValue("locationCountry", data.locationCountry ?? "");
- form.setValue("locationLat", data.lat);
- form.setValue("locationLng", data.lng);
- form.setValue("locationDescription", data.locationDescription ?? "");
-
- form.setValue("aoId", data.aoId ?? null);
- form.setValue("aoName", data.aoName ?? "");
- form.setValue("aoLogo", data.aoLogo ?? "");
- form.setValue("aoWebsite", data.aoWebsite ?? "");
-
- form.setValue("eventId", data.eventId ?? null);
- form.setValue("eventName", data.workoutName ?? "");
- // startTime: convertHHmmToHH_mm(event?.startTime ?? ""),
- // endTime: convertHHmmToHH_mm(event?.endTime ?? ""),
- form.setValue("eventStartTime", convertHHmmToHH_mm(data.startTime ?? ""));
- form.setValue("eventEndTime", convertHHmmToHH_mm(data.endTime ?? ""));
- form.setValue("eventDescription", data.eventDescription ?? "");
- if (data.dayOfWeek) {
- form.setValue("eventDayOfWeek", data.dayOfWeek);
- } else {
- // @ts-expect-error -- must remove dayOfWeek from form
- form.setValue("eventDayOfWeek", null);
- }
- form.setValue("eventTypeIds", data.eventTypeIds);
-
- form.setValue("submittedBy", session?.email || appStore.get("myEmail"));
- }, [data, eventTypes, form, session?.email]);
-
- return (
-
- );
-};
diff --git a/apps/map/src/app/_components/modal/update/create-ao-and-location-and-event-modal.tsx b/apps/map/src/app/_components/modal/update/create-ao-and-location-and-event-modal.tsx
new file mode 100644
index 00000000..0c5abd11
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/create-ao-and-location-and-event-modal.tsx
@@ -0,0 +1,61 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { CreateAOAndLocationAndEventType } from "@acme/validators/request-schemas";
+import { isProductionNodeEnv } from "@acme/shared/common/constants";
+import { Form } from "@acme/ui/form";
+import { CreateAOAndLocationAndEventSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { FormDebugData } from "~/app/_components/forms/dev-debug-component";
+import { ContactDetailsForm } from "~/app/_components/forms/form-inputs/contact-details-form";
+import { BaseModal } from "~/app/_components/modal/base-modal";
+import { client } from "~/orpc/client";
+import { AODetailsForm } from "../../forms/form-inputs/ao-details-form";
+import { EventDetailsForm } from "../../forms/form-inputs/event-details-form";
+import { InRegionForm } from "../../forms/form-inputs/in-region-form";
+import { LocationDetailsForm } from "../../forms/form-inputs/location-details-form";
+import { SubmitSection } from "../../forms/submit-section";
+
+export const CreateAOAndLocationAndEventModal = ({
+ data,
+}: {
+ data: DataType[ModalType.CREATE_AO_AND_LOCATION_AND_EVENT];
+}) => {
+ console.log("CreateAOAndLocationAndEventModal data", data);
+ const form = useForm({
+ resolver: zodResolver(CreateAOAndLocationAndEventSchema),
+ defaultValues: data,
+ mode: "onBlur",
+ });
+
+ const handleSubmission = async (values: CreateAOAndLocationAndEventType) => {
+ if ("badImage" in values && values.badImage && !!values.aoLogo) {
+ form.setError("aoLogo", { message: "Invalid image URL" });
+ throw new Error("Invalid image URL");
+ }
+
+ return await client.request.submitCreateAOAndLocationAndEventRequest(
+ values,
+ );
+ };
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/create-event-modal.tsx b/apps/map/src/app/_components/modal/update/create-event-modal.tsx
new file mode 100644
index 00000000..50576d47
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/create-event-modal.tsx
@@ -0,0 +1,44 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { CreateEventType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { CreateEventSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { FormDebugData } from "../../forms/dev-debug-component";
+import { ContactDetailsForm } from "../../forms/form-inputs/contact-details-form";
+import { EventDetailsForm } from "../../forms/form-inputs/event-details-form";
+import { SubmitSection } from "../../forms/submit-section";
+import { BaseModal } from "../base-modal";
+
+export const CreateEventModal = ({
+ data,
+}: {
+ data: DataType[ModalType.CREATE_EVENT];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(CreateEventSchema),
+ defaultValues: data,
+ });
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/delete-ao-modal.tsx b/apps/map/src/app/_components/modal/update/delete-ao-modal.tsx
new file mode 100644
index 00000000..94bc9791
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/delete-ao-modal.tsx
@@ -0,0 +1,46 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { DeleteAOType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { DeleteAOSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { FormDebugData } from "../../forms/dev-debug-component";
+import { ContactDetailsForm } from "../../forms/form-inputs/contact-details-form";
+import { DeleteAoForm } from "../../forms/form-inputs/delete-ao-form";
+import { SubmitSection } from "../../forms/submit-section";
+import { BaseModal } from "../../modal/base-modal";
+
+export const DeleteAoModal = ({
+ data,
+}: {
+ data: DataType[ModalType.DELETE_AO];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(DeleteAOSchema),
+ defaultValues: data,
+ mode: "onBlur",
+ });
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/delete-event-modal.tsx b/apps/map/src/app/_components/modal/update/delete-event-modal.tsx
new file mode 100644
index 00000000..14012e0a
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/delete-event-modal.tsx
@@ -0,0 +1,46 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { DeleteEventType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { DeleteEventSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { FormDebugData } from "../../forms/dev-debug-component";
+import { ContactDetailsForm } from "../../forms/form-inputs/contact-details-form";
+import { DeleteEventForm } from "../../forms/form-inputs/delete-event-form";
+import { SubmitSection } from "../../forms/submit-section";
+import { BaseModal } from "../base-modal";
+
+export const DeleteEventModal = ({
+ data,
+}: {
+ data: DataType[ModalType.DELETE_EVENT];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(DeleteEventSchema),
+ defaultValues: data,
+ mode: "onBlur",
+ });
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/edit-ao-and-location-modal.tsx b/apps/map/src/app/_components/modal/update/edit-ao-and-location-modal.tsx
new file mode 100644
index 00000000..6b0a0b60
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/edit-ao-and-location-modal.tsx
@@ -0,0 +1,54 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { EditAOAndLocationType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { EditAOAndLocationSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { FormDebugData } from "~/app/_components/forms/dev-debug-component";
+import { ContactDetailsForm } from "~/app/_components/forms/form-inputs/contact-details-form";
+import { BaseModal } from "~/app/_components/modal/base-modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { AODetailsForm } from "../../forms/form-inputs/ao-details-form";
+import { LocationDetailsForm } from "../../forms/form-inputs/location-details-form";
+import { SubmitSection } from "../../forms/submit-section";
+
+export const EditAoAndLocationModal = ({
+ data,
+}: {
+ data: DataType[ModalType.EDIT_AO_AND_LOCATION];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(EditAOAndLocationSchema),
+ defaultValues: data,
+ mode: "onBlur",
+ });
+
+ const handleSubmission = async (values: EditAOAndLocationType) => {
+ if ("badImage" in values && values.badImage && !!values.aoLogo) {
+ form.setError("aoLogo", { message: "Invalid image URL" });
+ throw new Error("Invalid image URL");
+ }
+
+ return await vanillaApi.request.submitEditAOAndLocationRequest(values);
+ };
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/edit-event-modal.tsx b/apps/map/src/app/_components/modal/update/edit-event-modal.tsx
new file mode 100644
index 00000000..349ee5d0
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/edit-event-modal.tsx
@@ -0,0 +1,45 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { EditEventType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { EditEventSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { FormDebugData } from "~/app/_components/forms/dev-debug-component";
+import { ContactDetailsForm } from "~/app/_components/forms/form-inputs/contact-details-form";
+import { BaseModal } from "~/app/_components/modal/base-modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { EventDetailsForm } from "../../forms/form-inputs/event-details-form";
+import { SubmitSection } from "../../forms/submit-section";
+
+export const EditEventModal = ({
+ data,
+}: {
+ data: DataType[ModalType.EDIT_EVENT];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(EditEventSchema),
+ defaultValues: data,
+ mode: "onBlur",
+ });
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/move-ao-to-different-location-modal.tsx b/apps/map/src/app/_components/modal/update/move-ao-to-different-location-modal.tsx
new file mode 100644
index 00000000..1c4bc084
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/move-ao-to-different-location-modal.tsx
@@ -0,0 +1,53 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { MoveAOToDifferentLocationType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { MoveAOToDifferentLocationSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { FormDebugData } from "~/app/_components/forms/dev-debug-component";
+import { ContactDetailsForm } from "~/app/_components/forms/form-inputs/contact-details-form";
+import { BaseModal } from "~/app/_components/modal/base-modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { ExistingLocationPickerForm } from "../../forms/form-inputs/existing-location-picker-form";
+import { SubmitSection } from "../../forms/submit-section";
+
+export const MoveAOToDifferentLocationModal = ({
+ data,
+}: {
+ data: DataType[ModalType.MOVE_AO_TO_DIFFERENT_LOCATION];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(MoveAOToDifferentLocationSchema),
+ defaultValues: data,
+ mode: "onBlur",
+ });
+
+ // TODO: Show the information about the ao that is being moved
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/move-ao-to-different-region-modal.tsx b/apps/map/src/app/_components/modal/update/move-ao-to-different-region-modal.tsx
new file mode 100644
index 00000000..f263e31e
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/move-ao-to-different-region-modal.tsx
@@ -0,0 +1,56 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { MoveAoToDifferentRegionType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { MoveAOToDifferentRegionSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { FormDebugData } from "~/app/_components/forms/dev-debug-component";
+import { ContactDetailsForm } from "~/app/_components/forms/form-inputs/contact-details-form";
+import { BaseModal } from "~/app/_components/modal/base-modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { RegionSelector } from "../../forms/form-inputs/region-selector";
+import { SubmitSection } from "../../forms/submit-section";
+
+export const MoveAOToDifferentRegionModal = ({
+ data,
+}: {
+ data: DataType[ModalType.MOVE_AO_TO_DIFFERENT_REGION];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(MoveAOToDifferentRegionSchema),
+ defaultValues: data,
+ mode: "onBlur",
+ });
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/move-ao-to-new-location-modal.tsx b/apps/map/src/app/_components/modal/update/move-ao-to-new-location-modal.tsx
new file mode 100644
index 00000000..e50e8356
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/move-ao-to-new-location-modal.tsx
@@ -0,0 +1,54 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { MoveAOToNewLocationType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { MoveAOToNewLocationSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { FormDebugData } from "~/app/_components/forms/dev-debug-component";
+import { ContactDetailsForm } from "~/app/_components/forms/form-inputs/contact-details-form";
+import { BaseModal } from "~/app/_components/modal/base-modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { LocationDetailsForm } from "../../forms/form-inputs/location-details-form";
+import { RegionAndAOSelector } from "../../forms/form-inputs/region-and-ao-selector";
+import { SubmitSection } from "../../forms/submit-section";
+
+export const MoveAOToNewLocationModal = ({
+ data,
+}: {
+ data: DataType[ModalType.MOVE_AO_TO_NEW_LOCATION];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(MoveAOToNewLocationSchema),
+ defaultValues: data,
+ });
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/move-event-to-different-ao-modal.tsx b/apps/map/src/app/_components/modal/update/move-event-to-different-ao-modal.tsx
new file mode 100644
index 00000000..44d47624
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/move-event-to-different-ao-modal.tsx
@@ -0,0 +1,59 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { MoveEventToDifferentAOType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { MoveEventToDifferentAOSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { FormDebugData } from "~/app/_components/forms/dev-debug-component";
+import { ContactDetailsForm } from "~/app/_components/forms/form-inputs/contact-details-form";
+import { BaseModal } from "~/app/_components/modal/base-modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { RegionAndAOSelector } from "../../forms/form-inputs/region-and-ao-selector";
+import { SubmitSection } from "../../forms/submit-section";
+
+export const MoveEventToDifferentAoModal = ({
+ data,
+}: {
+ data: DataType[ModalType.MOVE_EVENT_TO_DIFFERENT_AO];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(MoveEventToDifferentAOSchema),
+ defaultValues: data,
+ });
+
+ console.log("MoveEventToDifferentAoModal data", data);
+ console.log("MoveEventToDifferentAoModal form", form.watch());
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/move-event-to-new-ao-modal.tsx b/apps/map/src/app/_components/modal/update/move-event-to-new-ao-modal.tsx
new file mode 100644
index 00000000..c82b48e7
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/move-event-to-new-ao-modal.tsx
@@ -0,0 +1,62 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { MoveEventToNewAOType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { MoveEventToNewAOSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { FormDebugData } from "~/app/_components/forms/dev-debug-component";
+import { ContactDetailsForm } from "~/app/_components/forms/form-inputs/contact-details-form";
+import { ExistingLocationPickerForm } from "~/app/_components/forms/form-inputs/existing-location-picker-form";
+import { BaseModal } from "~/app/_components/modal/base-modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { AODetailsForm } from "../../forms/form-inputs/ao-details-form";
+import { LocationDetailsForm } from "../../forms/form-inputs/location-details-form";
+import { RegionSelector } from "../../forms/form-inputs/region-selector";
+import { SubmitSection } from "../../forms/submit-section";
+
+export const MoveEventToNewAoModal = ({
+ data,
+}: {
+ data: DataType[ModalType.MOVE_EVENT_TO_NEW_AO];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(MoveEventToNewAOSchema),
+ defaultValues: data,
+ });
+
+ const formNewLocationId = form.watch("newLocationId");
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/update/move-event-to-new-location-modal.tsx b/apps/map/src/app/_components/modal/update/move-event-to-new-location-modal.tsx
new file mode 100644
index 00000000..0b06922c
--- /dev/null
+++ b/apps/map/src/app/_components/modal/update/move-event-to-new-location-modal.tsx
@@ -0,0 +1,55 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+
+import type { MoveEventToNewLocationType } from "@acme/validators/request-schemas";
+import { Form } from "@acme/ui/form";
+import { MoveEventToNewLocationSchema } from "@acme/validators/request-schemas";
+
+import type { DataType, ModalType } from "~/utils/store/modal";
+import { FormDebugData } from "~/app/_components/forms/dev-debug-component";
+import { ContactDetailsForm } from "~/app/_components/forms/form-inputs/contact-details-form";
+import { BaseModal } from "~/app/_components/modal/base-modal";
+import { isProd } from "~/trpc/util";
+import { vanillaApi } from "~/trpc/vanilla";
+import { LocationDetailsForm } from "../../forms/form-inputs/location-details-form";
+import { RegionAOEventSelector } from "../../forms/form-inputs/region-ao-event-selector";
+import { SubmitSection } from "../../forms/submit-section";
+
+export const MoveEventToNewLocationModal = ({
+ data,
+}: {
+ data: DataType[ModalType.MOVE_EVENT_TO_NEW_LOCATION];
+}) => {
+ const form = useForm({
+ resolver: zodResolver(MoveEventToNewLocationSchema),
+ defaultValues: data,
+ });
+
+ return (
+
+
+
+
+ );
+};
diff --git a/apps/map/src/app/_components/modal/utils/handle-submission-error.ts b/apps/map/src/app/_components/modal/utils/handle-submission-error.ts
new file mode 100644
index 00000000..7957f33f
--- /dev/null
+++ b/apps/map/src/app/_components/modal/utils/handle-submission-error.ts
@@ -0,0 +1,60 @@
+import { ORPCError } from "@orpc/client";
+import isObject from "lodash/isObject";
+import { ZodError } from "zod";
+
+import { Case } from "@acme/shared/common/enums";
+import { convertCase } from "@acme/shared/common/functions";
+import { toast } from "@acme/ui/toast";
+
+export const handleSubmissionError = (error: unknown): void => {
+ let errorMessage: string;
+
+ if (error instanceof ZodError) {
+ console.error("handleSubmissionError ZodError", error);
+ const errorMessages = error.errors
+ .map((err) => {
+ if (err?.message) {
+ return `${err.path.join(".")}: ${err.message}`;
+ }
+ return null;
+ })
+ .filter(Boolean);
+
+ errorMessage =
+ errorMessages.length > 0
+ ? errorMessages.join(", ")
+ : "Form validation failed";
+ } else if (error instanceof ORPCError) {
+ console.error("handleSubmissionError error is an ORPCError", error);
+ errorMessage = error.message;
+ } else if (isObject(error)) {
+ console.error("handleSubmissionError error is object", error);
+ const errorMessages = Object.entries(
+ error as { message: string; type: string }[],
+ )
+ .map(([key, err]) => {
+ const keyWords = convertCase({ str: key, toCase: Case.TitleCase });
+ if (err?.message) {
+ return `${keyWords}: ${err.message}`;
+ }
+ return null;
+ })
+ .filter(Boolean);
+
+ errorMessage =
+ errorMessages.length > 0
+ ? errorMessages.join(", ")
+ : "Form validation failed";
+ } else if (!(error instanceof Error)) {
+ console.error("handleSubmissionError error is not an Error", error);
+ errorMessage = "Failed to submit update request";
+ } else if (!(error instanceof ORPCError)) {
+ console.error("handleSubmissionError error is not an ORPCError", error);
+ errorMessage = error.message;
+ } else {
+ console.error("handleSubmissionError else", error);
+ errorMessage = "Failed to submit update request";
+ }
+
+ toast.error(errorMessage);
+};
diff --git a/apps/map/src/app/_components/modal/workout-details-modal.tsx b/apps/map/src/app/_components/modal/workout-details-modal.tsx
index dbbfd813..a58b4a10 100644
--- a/apps/map/src/app/_components/modal/workout-details-modal.tsx
+++ b/apps/map/src/app/_components/modal/workout-details-modal.tsx
@@ -5,8 +5,14 @@ import { Dialog, DialogContent, DialogHeader } from "@acme/ui/dialog";
import type { DataType, ModalType } from "~/utils/store/modal";
import { orpc, useQuery } from "~/orpc/react";
+import { appStore } from "~/utils/store/app";
import { closeModal } from "~/utils/store/modal";
import { selectedItemStore } from "~/utils/store/selected-item";
+import {
+ formatTime,
+ getShortDayOfWeek,
+ LocationEditButtons,
+} from "../map/location-edit-buttons";
import { WorkoutDetailsContent } from "../workout/workout-details-content";
export const WorkoutDetailsModal = ({
@@ -16,6 +22,7 @@ export const WorkoutDetailsModal = ({
}) => {
const selectedLocationId = selectedItemStore.use.locationId();
const selectedEventId = selectedItemStore.use.eventId();
+ const mode = appStore.use.mode();
const providedLocationId =
typeof data.locationId === "number" ? data.locationId : -1;
const locationId = selectedLocationId ?? providedLocationId;
@@ -29,20 +36,52 @@ export const WorkoutDetailsModal = ({
results?.location.events.find((e) => e.id === selectedEventId)?.id ??
results?.location.events[0]?.id ??
null;
+ const modalAOIds = results?.location.events.map((e) => e.aoId);
+
const width = useWindowWidth();
const isLarge = width > Number(BreakPoints.LG);
const isMedium = width > Number(BreakPoints.MD);
+ // Get selected event details for edit buttons
+ const selectedEvent = results?.location.events.find(
+ (event) => event.id === modalEventId,
+ );
+ const eventName = selectedEvent?.name ?? "Workout";
+ const aoName = results?.location.parentName ?? "AO";
+ const aoId = selectedEvent?.aoId ?? modalAOIds?.[0] ?? null;
+ console.log("aoId", aoId, modalAOIds, results);
+
+ // Format time display
+ const shortDayOfWeek = getShortDayOfWeek(selectedEvent?.dayOfWeek);
+ const formattedTime = formatTime(selectedEvent?.startTime);
+ const timeDisplay =
+ shortDayOfWeek && formattedTime ? `${shortDayOfWeek} ${formattedTime}` : "";
+
return (