diff --git a/.env.example b/.env.example index fbce214e..6acfa935 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,14 @@ CORS_ORIGINS=http://localhost:3000,http://localhost:5173 # Keep this false for local/dev/staging environments. SEARCH_ENGINE_INDEXING_ENABLED=false +# Feedback form email delivery. In Kamal production, sendmail is provided by +# msmtp-mta and relays through smtp.umn.edu. Use FEEDBACK_RECIPIENTS to route +# public feedback to the Geoportal team. +FEEDBACK_EMAIL_ENABLED=true +FEEDBACK_RECIPIENTS="ewlarson@gmail.com,majew030@umn.edu" +FEEDBACK_FROM="BTAA Geoportal " +FEEDBACK_DELIVERY=sendmail + # Old Database OLD_DB_NAME=geoportal_production_20251030 diff --git a/backend/app/api/v1/endpoint_modules/feedback.py b/backend/app/api/v1/endpoint_modules/feedback.py new file mode 100644 index 00000000..b8c7ff95 --- /dev/null +++ b/backend/app/api/v1/endpoint_modules/feedback.py @@ -0,0 +1,90 @@ +import logging +import re +from typing import Any + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field, field_validator + +from app.services.feedback_service import ( + FEEDBACK_TOPICS, + FeedbackDeliveryUnavailable, + FeedbackSubmission, + send_feedback_email, +) + +logger = logging.getLogger(__name__) +router = APIRouter() + + +class FeedbackRequest(BaseModel): + name: str = Field(default="", max_length=120) + email_address: str = Field(default="", max_length=254) + topic: str = Field(..., min_length=1, max_length=80) + description: str = Field(..., min_length=1, max_length=5000) + contact_info: str = Field(default="", max_length=500) + source_url: str = Field(default="", max_length=1000) + user_agent: str = Field(default="", max_length=1000) + + @field_validator("*", mode="before") + @classmethod + def strip_string_fields(cls, value: Any) -> Any: + if isinstance(value, str): + return value.strip() + return value + + @field_validator("topic") + @classmethod + def topic_must_be_known(cls, value: str) -> str: + if value not in FEEDBACK_TOPICS: + raise ValueError("Select a feedback topic.") + return value + + @field_validator("email_address") + @classmethod + def email_must_be_valid_when_present(cls, value: str) -> str: + if value and not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", value): + raise ValueError("Enter a valid email address.") + return value + + +@router.post("/feedback", include_in_schema=False) +async def submit_feedback(payload: FeedbackRequest, request: Request): + submission = FeedbackSubmission( + name=payload.name, + email_address=payload.email_address, + topic=payload.topic, + description=payload.description, + contact_info=payload.contact_info, + source_url=payload.source_url or request.headers.get("referer", ""), + user_agent=payload.user_agent or request.headers.get("user-agent", ""), + ) + + try: + result = send_feedback_email(submission) + except FeedbackDeliveryUnavailable as exc: + logger.warning("Feedback delivery unavailable: %s", exc) + return JSONResponse( + status_code=503, + content={"message": "Feedback delivery is temporarily unavailable."}, + ) + except Exception: + logger.exception("Unexpected feedback delivery failure") + return JSONResponse( + status_code=503, + content={"message": "Feedback delivery is temporarily unavailable."}, + ) + + return JSONResponse( + status_code=202, + content={ + "data": { + "type": "feedback-submission", + "id": "submitted", + "attributes": { + "accepted": True, + "sent": bool(result.get("sent")), + }, + } + }, + ) diff --git a/backend/app/api/v1/endpoint_modules/root.py b/backend/app/api/v1/endpoint_modules/root.py index 07f4a5f5..3f11f7dd 100644 --- a/backend/app/api/v1/endpoint_modules/root.py +++ b/backend/app/api/v1/endpoint_modules/root.py @@ -25,6 +25,7 @@ async def api_root(request: Request): ), "endpoints": [ "/api/v1/", + "/api/v1/feedback", "/api/v1/home/blog-posts", "/api/v1/search", "/api/v1/search/facets/{facet_name}", diff --git a/backend/app/api/v1/endpoints.py b/backend/app/api/v1/endpoints.py index a1dcf8b5..e4c58a08 100644 --- a/backend/app/api/v1/endpoints.py +++ b/backend/app/api/v1/endpoints.py @@ -4,6 +4,7 @@ from .endpoint_modules.admin import router as admin_router from .endpoint_modules.analytics import router as analytics_router +from .endpoint_modules.feedback import router as feedback_router from .endpoint_modules.gazetteer import router as gazetteer_router from .endpoint_modules.home import router as home_router from .endpoint_modules.map import router as map_router @@ -29,6 +30,7 @@ router.include_router(root_router, tags=["root"]) router.include_router(search_router, tags=["search"]) router.include_router(analytics_router, tags=["analytics"], include_in_schema=False) +router.include_router(feedback_router, tags=["feedback"], include_in_schema=False) router.include_router(home_router, tags=["home"]) router.include_router(resources_router, tags=["resources"]) router.include_router(thumbnails_router, tags=["thumbnails"]) diff --git a/backend/app/services/feedback_service.py b/backend/app/services/feedback_service.py new file mode 100644 index 00000000..c8ed5f1e --- /dev/null +++ b/backend/app/services/feedback_service.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import os +import re +import smtplib +import subprocess +from dataclasses import dataclass +from email.message import EmailMessage +from email.utils import formataddr + +FEEDBACK_TOPICS = { + "Correction", + "Question", + "Comments or Suggestions", + "Harmful language", + "Other", +} +DEFAULT_FEEDBACK_RECIPIENTS = "majew030@umn.edu,btaa-gdp@umn.edu,geoportal@btaa.org" + + +class FeedbackDeliveryUnavailable(RuntimeError): + """Raised when feedback mail cannot be delivered with current configuration.""" + + +@dataclass(frozen=True) +class FeedbackSubmission: + name: str + email_address: str + topic: str + description: str + source_url: str = "" + user_agent: str = "" + contact_info: str = "" + + +def _env_bool(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def _env_int(name: str, default: int) -> int: + try: + return int(os.getenv(name, str(default))) + except (TypeError, ValueError): + return default + + +def _split_recipients(value: str | None) -> list[str]: + if not value: + return [] + return [part.strip() for part in re.split(r"[,;\n]", value) if part.strip()] + + +def _feedback_delivery() -> str: + configured = os.getenv("FEEDBACK_DELIVERY") or os.getenv("BRIDGE_SYNC_REPORT_DELIVERY") + if configured: + return configured.strip().lower() + return "sendmail" if os.path.exists("/usr/sbin/sendmail") else "smtp" + + +def _sender() -> str: + sender = os.getenv("FEEDBACK_FROM") or os.getenv("SMTP_FROM") + if sender: + return sender + return formataddr(("BTAA Geoportal", "no-reply@geo.btaa.org")) + + +def _subject(topic: str) -> str: + prefix = os.getenv("FEEDBACK_SUBJECT_PREFIX", "BTAA Geoportal Feedback") + return f"{prefix}: {topic}" + + +def _build_message(submission: FeedbackSubmission, recipients: list[str]) -> EmailMessage: + message = EmailMessage() + message["Subject"] = _subject(submission.topic) + message["From"] = _sender() + message["To"] = ", ".join(recipients) + + if submission.email_address: + reply_name = submission.name or submission.email_address + message["Reply-To"] = formataddr((reply_name, submission.email_address)) + + submitted_by = submission.name or "Not provided" + submitted_email = submission.email_address or "Not provided" + source_url = submission.source_url or "Not provided" + user_agent = submission.user_agent or "Not provided" + + message.set_content( + "\n".join( + [ + "A BTAA Geoportal feedback form was submitted.", + "", + f"Topic: {submission.topic}", + f"Name: {submitted_by}", + f"Email: {submitted_email}", + f"Source URL: {source_url}", + f"User-Agent: {user_agent}", + "", + "Description:", + submission.description, + ] + ) + ) + return message + + +def send_feedback_email(submission: FeedbackSubmission) -> dict: + if not _env_bool("FEEDBACK_EMAIL_ENABLED", True): + raise FeedbackDeliveryUnavailable("feedback_email_disabled") + + if submission.contact_info.strip(): + return {"sent": False, "reason": "honeypot"} + + recipients = _split_recipients(os.getenv("FEEDBACK_RECIPIENTS", DEFAULT_FEEDBACK_RECIPIENTS)) + if not recipients: + raise FeedbackDeliveryUnavailable("no_feedback_recipients") + + delivery = _feedback_delivery() + message = _build_message(submission, recipients) + + if delivery == "sendmail": + sendmail_path = os.getenv("SENDMAIL_PATH", "/usr/sbin/sendmail") + sendmail_args = os.getenv("SENDMAIL_ARGS", "-t -i").split() + try: + subprocess.run( + [sendmail_path, *sendmail_args], + input=message.as_bytes(), + check=True, + timeout=_env_int("SENDMAIL_TIMEOUT_SECONDS", 20), + ) + except (OSError, subprocess.SubprocessError) as exc: + raise FeedbackDeliveryUnavailable("sendmail_failed") from exc + return {"sent": True, "delivery": "sendmail", "recipients": len(recipients)} + + host = os.getenv("SMTP_HOST") + if not host: + raise FeedbackDeliveryUnavailable("no_smtp_host") + + port = _env_int("SMTP_PORT", 587) + timeout = _env_int("SMTP_TIMEOUT_SECONDS", 20) + username = os.getenv("SMTP_USERNAME") + password = os.getenv("SMTP_PASSWORD") + use_ssl = _env_bool("SMTP_SSL", False) + use_starttls = _env_bool("SMTP_STARTTLS", not use_ssl) + + smtp_cls = smtplib.SMTP_SSL if use_ssl else smtplib.SMTP + try: + with smtp_cls(host, port, timeout=timeout) as smtp: + if use_starttls and not use_ssl: + smtp.starttls() + if username and password: + smtp.login(username, password) + smtp.send_message(message) + except OSError as exc: + raise FeedbackDeliveryUnavailable("smtp_failed") from exc + + return {"sent": True, "delivery": "smtp", "recipients": len(recipients)} diff --git a/backend/templates/docs.html b/backend/templates/docs.html index 020753ec..80d05d5e 100644 --- a/backend/templates/docs.html +++ b/backend/templates/docs.html @@ -22,7 +22,7 @@

BTAA Geospatial API

@@ -33,7 +33,7 @@

BTAA Geospatial API

DRAFT Specification / Work in Progress

-

This portion of the BTAA GIN site, our BTAA Geospatial API, and our linked data offerings are a WORK IN PROGRESS. Please reach out if you have questions or wish to participate in bringing these resources to public release.

+

This portion of the BTAA GIN site, our BTAA Geospatial API, and our linked data offerings are a WORK IN PROGRESS. Please reach out if you have questions or wish to participate in bringing these resources to public release.

@@ -58,7 +58,7 @@

About & Help

Program Updates
  • - Contact Us + Contact Us
  • Help Guides @@ -181,4 +181,3 @@

    BTAA Member Libraries

    - diff --git a/backend/tests/api/v1/test_feedback_endpoint.py b/backend/tests/api/v1/test_feedback_endpoint.py new file mode 100644 index 00000000..a992bae4 --- /dev/null +++ b/backend/tests/api/v1/test_feedback_endpoint.py @@ -0,0 +1,94 @@ +import pytest_asyncio +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.v1.endpoint_modules.feedback import router +from app.services.feedback_service import FeedbackDeliveryUnavailable + + +@pytest_asyncio.fixture(scope="session", autouse=True) +async def setup_test_database(): + yield + + +@pytest_asyncio.fixture(scope="session", autouse=True) +async def db_connection(): + yield + + +@pytest_asyncio.fixture(autouse=True) +async def db_transaction(): + yield + + +def _make_app() -> FastAPI: + app = FastAPI() + app.include_router(router, prefix="/api/v1") + return app + + +def test_submit_feedback_sends_email(monkeypatch): + submissions = [] + + def fake_send_feedback_email(submission): + submissions.append(submission) + return {"sent": True, "delivery": "sendmail", "recipients": 1} + + monkeypatch.setattr( + "app.api.v1.endpoint_modules.feedback.send_feedback_email", + fake_send_feedback_email, + ) + + client = TestClient(_make_app()) + response = client.post( + "/api/v1/feedback", + json={ + "name": "Ada Lovelace", + "email_address": "ada@example.edu", + "topic": "Comments or Suggestions", + "description": "The new search page is helpful.", + "source_url": "https://geo.example.org/feedback", + "user_agent": "pytest", + }, + ) + + assert response.status_code == 202 + attributes = response.json()["data"]["attributes"] + assert attributes == {"accepted": True, "sent": True} + assert submissions[0].topic == "Comments or Suggestions" + assert submissions[0].description == "The new search page is helpful." + + +def test_submit_feedback_rejects_unknown_topic(): + client = TestClient(_make_app()) + response = client.post( + "/api/v1/feedback", + json={ + "topic": "Nope", + "description": "Feedback body", + }, + ) + + assert response.status_code == 422 + + +def test_submit_feedback_reports_delivery_unavailable(monkeypatch): + def fake_send_feedback_email(submission): + raise FeedbackDeliveryUnavailable("no_smtp_host") + + monkeypatch.setattr( + "app.api.v1.endpoint_modules.feedback.send_feedback_email", + fake_send_feedback_email, + ) + + client = TestClient(_make_app()) + response = client.post( + "/api/v1/feedback", + json={ + "topic": "Question", + "description": "Can someone follow up?", + }, + ) + + assert response.status_code == 503 + assert response.json()["message"] == "Feedback delivery is temporarily unavailable." diff --git a/backend/tests/services/test_feedback_service.py b/backend/tests/services/test_feedback_service.py new file mode 100644 index 00000000..d1b52e2a --- /dev/null +++ b/backend/tests/services/test_feedback_service.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import pytest +import pytest_asyncio + +from app.services.feedback_service import ( + FeedbackDeliveryUnavailable, + FeedbackSubmission, + send_feedback_email, +) + + +@pytest_asyncio.fixture(scope="session", autouse=True) +async def setup_test_database(): + yield + + +@pytest_asyncio.fixture(scope="session", autouse=True) +async def db_connection(): + yield + + +@pytest_asyncio.fixture(autouse=True) +async def db_transaction(): + yield + + +def _submission(**overrides) -> FeedbackSubmission: + values = { + "name": "Ada Lovelace", + "email_address": "ada@example.edu", + "topic": "Question", + "description": "Can this record link to a newer dataset?", + "source_url": "https://geo.example.org/feedback", + "user_agent": "pytest", + } + values.update(overrides) + return FeedbackSubmission(**values) + + +def test_send_feedback_email_supports_sendmail(monkeypatch): + calls = [] + + def fake_run(cmd, *, input, check, timeout): + calls.append( + { + "cmd": cmd, + "input": input, + "check": check, + "timeout": timeout, + } + ) + + monkeypatch.setenv("FEEDBACK_EMAIL_ENABLED", "true") + monkeypatch.setenv("FEEDBACK_DELIVERY", "sendmail") + monkeypatch.setenv("FEEDBACK_RECIPIENTS", "team-a@example.edu,team-b@example.edu") + monkeypatch.setenv("SENDMAIL_PATH", "/usr/local/bin/sendmail") + monkeypatch.setenv("SENDMAIL_ARGS", "-t -i") + monkeypatch.setattr("app.services.feedback_service.subprocess.run", fake_run) + + result = send_feedback_email(_submission()) + + assert result == { + "sent": True, + "delivery": "sendmail", + "recipients": 2, + } + assert calls[0]["cmd"] == ["/usr/local/bin/sendmail", "-t", "-i"] + assert calls[0]["check"] is True + assert b"BTAA Geoportal Feedback: Question" in calls[0]["input"] + assert b"Can this record link to a newer dataset?" in calls[0]["input"] + + +def test_send_feedback_email_ignores_honeypot(monkeypatch): + calls = [] + + monkeypatch.setenv("FEEDBACK_EMAIL_ENABLED", "true") + monkeypatch.setenv("FEEDBACK_DELIVERY", "sendmail") + monkeypatch.setattr( + "app.services.feedback_service.subprocess.run", + lambda *args, **kwargs: calls.append((args, kwargs)), + ) + + result = send_feedback_email(_submission(contact_info="please email me")) + + assert result == {"sent": False, "reason": "honeypot"} + assert calls == [] + + +def test_send_feedback_email_raises_without_smtp_host(monkeypatch): + monkeypatch.setenv("FEEDBACK_EMAIL_ENABLED", "true") + monkeypatch.setenv("FEEDBACK_DELIVERY", "smtp") + monkeypatch.setenv("FEEDBACK_RECIPIENTS", "team@example.edu") + monkeypatch.delenv("SMTP_HOST", raising=False) + + with pytest.raises(FeedbackDeliveryUnavailable): + send_feedback_email(_submission()) diff --git a/config/deploy.dev1.yml b/config/deploy.dev1.yml index 4ae46a0d..1fafc4e9 100644 --- a/config/deploy.dev1.yml +++ b/config/deploy.dev1.yml @@ -49,6 +49,13 @@ env: TURNSTILE_ALLOWED_HOSTNAMES: lib-btaageoapi-dev-app-01.oit.umn.edu TURNSTILE_COOKIE_SECURE: "true" TURNSTILE_EXPECTED_ACTION: geoportal_gate + FEEDBACK_EMAIL_ENABLED: "true" + FEEDBACK_DELIVERY: "<%= ENV.fetch('FEEDBACK_DELIVERY', 'smtp') %>" + SENDMAIL_PATH: "<%= ENV.fetch('SENDMAIL_PATH', '/usr/sbin/sendmail') %>" + SENDMAIL_ARGS: "<%= ENV.fetch('SENDMAIL_ARGS', '-t -i') %>" + SMTP_HOST: "<%= ENV.fetch('SMTP_HOST', '172.18.0.1') %>" + SMTP_PORT: "<%= ENV.fetch('SMTP_PORT', '25') %>" + SMTP_STARTTLS: "<%= ENV.fetch('SMTP_STARTTLS', 'false') %>" WEB_UVICORN_WORKERS: "3" WEB_INTERNAL_UVICORN_WORKERS: "4" WEB_SSR_WORKERS: "3" diff --git a/config/deploy.dev2.yml b/config/deploy.dev2.yml index 60168038..cf5789e8 100644 --- a/config/deploy.dev2.yml +++ b/config/deploy.dev2.yml @@ -49,6 +49,13 @@ env: TURNSTILE_ALLOWED_HOSTNAMES: geodev.btaa.org,lib-geoportal-dev-web-01.oit.umn.edu TURNSTILE_COOKIE_SECURE: "true" TURNSTILE_EXPECTED_ACTION: geoportal_gate + FEEDBACK_EMAIL_ENABLED: "true" + FEEDBACK_DELIVERY: "<%= ENV.fetch('FEEDBACK_DELIVERY', 'smtp') %>" + SENDMAIL_PATH: "<%= ENV.fetch('SENDMAIL_PATH', '/usr/sbin/sendmail') %>" + SENDMAIL_ARGS: "<%= ENV.fetch('SENDMAIL_ARGS', '-t -i') %>" + SMTP_HOST: "<%= ENV.fetch('SMTP_HOST', '172.18.0.1') %>" + SMTP_PORT: "<%= ENV.fetch('SMTP_PORT', '25') %>" + SMTP_STARTTLS: "<%= ENV.fetch('SMTP_STARTTLS', 'false') %>" RATE_LIMIT_ENABLED: "true" API_KEY_TIER_CACHE_TTL_SECONDS: "60" API_KEY_LAST_USED_UPDATE_INTERVAL_SECONDS: "60" diff --git a/config/deploy.prd.yml b/config/deploy.prd.yml index 09cb5375..96a6c342 100644 --- a/config/deploy.prd.yml +++ b/config/deploy.prd.yml @@ -62,8 +62,13 @@ env: KAMAL_DEST: prd BRIDGE_SYNC_REPORT_ENABLED: "true" BRIDGE_SYNC_REPORT_DELIVERY: sendmail - SENDMAIL_PATH: /usr/sbin/sendmail - SENDMAIL_ARGS: "-t -i" + FEEDBACK_EMAIL_ENABLED: "true" + FEEDBACK_DELIVERY: "<%= ENV.fetch('FEEDBACK_DELIVERY', 'smtp') %>" + SENDMAIL_PATH: "<%= ENV.fetch('SENDMAIL_PATH', '/usr/sbin/sendmail') %>" + SENDMAIL_ARGS: "<%= ENV.fetch('SENDMAIL_ARGS', '-t -i') %>" + SMTP_HOST: "<%= ENV.fetch('SMTP_HOST', '172.18.0.1') %>" + SMTP_PORT: "<%= ENV.fetch('SMTP_PORT', '25') %>" + SMTP_STARTTLS: "<%= ENV.fetch('SMTP_STARTTLS', 'false') %>" SEARCH_ENGINE_INDEXING_ENABLED: "false" RATE_LIMIT_ENABLED: "true" TURNSTILE_ENABLED: "true" @@ -116,6 +121,12 @@ env: - BTAA_GEOSPATIAL_API_KEY - TURNSTILE_SECRET_KEY - APPSIGNAL_PUSH_API_KEY +<% if ENV.fetch('SMTP_USERNAME', '').strip != '' %> + - SMTP_USERNAME +<% end %> +<% if ENV.fetch('SMTP_PASSWORD', '').strip != '' %> + - SMTP_PASSWORD +<% end %> - SLACK_SIGNING_SECRET - AWS_ACCESS_KEY_ID - AWS_SECRET_ACCESS_KEY diff --git a/config/deploy.yml b/config/deploy.yml index 30bb512f..31dce041 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -146,11 +146,17 @@ env: BRIDGE_SYNC_REPORT_FROM: "<%= ENV.fetch('BRIDGE_SYNC_REPORT_FROM', 'BTAA Geoportal ') %>" BRIDGE_SYNC_REPORT_DESTINATIONS: "<%= ENV.fetch('BRIDGE_SYNC_REPORT_DESTINATIONS', 'prd') %>" BRIDGE_SYNC_REPORT_DELIVERY: "<%= ENV.fetch('BRIDGE_SYNC_REPORT_DELIVERY', 'smtp') %>" + FEEDBACK_EMAIL_ENABLED: "<%= ENV.fetch('FEEDBACK_EMAIL_ENABLED', 'false') %>" + FEEDBACK_RECIPIENTS: "<%= ENV.fetch('FEEDBACK_RECIPIENTS', 'majew030@umn.edu,btaa-gdp@umn.edu,geoportal@btaa.org') %>" + FEEDBACK_FROM: "<%= ENV.fetch('FEEDBACK_FROM', 'BTAA Geoportal ') %>" + FEEDBACK_DELIVERY: "<%= ENV.fetch('FEEDBACK_DELIVERY', ENV.fetch('BRIDGE_SYNC_REPORT_DELIVERY', 'smtp')) %>" SENDMAIL_PATH: "<%= ENV.fetch('SENDMAIL_PATH', '/usr/sbin/sendmail') %>" SENDMAIL_ARGS: "<%= ENV.fetch('SENDMAIL_ARGS', '-t -i') %>" SMTP_HOST: "<%= ENV.fetch('SMTP_HOST', '') %>" SMTP_PORT: "<%= ENV.fetch('SMTP_PORT', '587') %>" SMTP_STARTTLS: "<%= ENV.fetch('SMTP_STARTTLS', 'true') %>" + SMTP_SSL: "<%= ENV.fetch('SMTP_SSL', 'false') %>" + SMTP_TIMEOUT_SECONDS: "<%= ENV.fetch('SMTP_TIMEOUT_SECONDS', '20') %>" BACKUP_ENABLED: "false" BACKUP_REQUIRED_DEST: "prd" BACKUP_RETENTION_COUNT: "3" @@ -175,6 +181,12 @@ env: - BTAA_GEOSPATIAL_API_KEY - TURNSTILE_SECRET_KEY - APPSIGNAL_PUSH_API_KEY +<% if ENV.fetch('SMTP_USERNAME', '').strip != '' %> + - SMTP_USERNAME +<% end %> +<% if ENV.fetch('SMTP_PASSWORD', '').strip != '' %> + - SMTP_PASSWORD +<% end %> # ────────────── Accessories (one per Compose service) ────────────── accessories: diff --git a/docs/backend/kamal_deployment.md b/docs/backend/kamal_deployment.md index ca400f79..4d1caeae 100644 --- a/docs/backend/kamal_deployment.md +++ b/docs/backend/kamal_deployment.md @@ -364,6 +364,62 @@ image installs `msmtp-mta`, which provides `/usr/sbin/sendmail`, and configures UMN's `smtp.umn.edu` relay without app-level SMTP credentials. UMN relay access may still require the production host/IP and the From address to be approved by OIT. +The public `/feedback` page submits to `/api/v1/feedback`, which uses the same sendmail +or SMTP delivery conventions. Configure: + +```bash +FEEDBACK_EMAIL_ENABLED=true +FEEDBACK_RECIPIENTS="majew030@umn.edu,btaa-gdp@umn.edu,geoportal@btaa.org" +FEEDBACK_FROM="BTAA Geoportal " +FEEDBACK_DELIVERY=sendmail +``` + +`dev1`, `dev2`, and `prd` enable feedback mail. Unless overridden with +`FEEDBACK_RECIPIENTS`, feedback goes to `majew030@umn.edu`, `btaa-gdp@umn.edu`, +and `geoportal@btaa.org`. + +On Kamal, host Postfix is the preferred no-password relay. Because the app runs +inside Docker, `localhost` means the app container, not the VM. The destination +Kamal configs point feedback SMTP at the host's Kamal bridge gateway: + +```bash +FEEDBACK_DELIVERY=smtp +SMTP_HOST=172.18.0.1 +SMTP_PORT=25 +SMTP_STARTTLS=false +``` + +Postfix must listen on that bridge address and permit the Kamal app network. +For the current Kamal hosts, the bridge is `172.18.0.1/16`, so the host-side +Postfix shape is: + +```bash +sudo postconf -e 'inet_interfaces = localhost, 172.18.0.1' +sudo postconf -e 'mynetworks = 127.0.0.0/8 [::1]/128 172.18.0.0/16' +sudo systemctl restart postfix +sudo firewall-cmd --zone=docker --add-port=25/tcp --permanent +sudo firewall-cmd --reload +``` + +Verify from inside the running web container that the host relay is reachable: + +```bash +docker exec bash -lc \ + 'timeout 3 bash -c "cat < /dev/null > /dev/tcp/172.18.0.1/25" && echo ok' +``` + +If the host relay cannot be approved, switch feedback to authenticated SMTP by +setting these in `.kamal/secrets.dev1` before deploying: + +```bash +export FEEDBACK_DELIVERY=smtp +export SMTP_HOST=smtp.umn.edu +export SMTP_PORT=587 +export SMTP_STARTTLS=true +export SMTP_USERNAME="" +export SMTP_PASSWORD="" +``` + For analytics retention, rollups, and storage behavior, see [Analytics Program](analytics_program.md). ## Destination Differences diff --git a/docs/make_tasks.md b/docs/make_tasks.md index 793eb03b..30d48364 100644 --- a/docs/make_tasks.md +++ b/docs/make_tasks.md @@ -47,6 +47,11 @@ Common overrides: Frontend lint, format, and tests are npm scripts run from `frontend/`; see [frontend/README.md](frontend/README.md). +The public `/feedback` page posts to `/api/v1/feedback` and sends mail with the +same sendmail/SMTP conventions as other app mail. Configure +`FEEDBACK_EMAIL_ENABLED`, `FEEDBACK_RECIPIENTS`, `FEEDBACK_FROM`, and +`FEEDBACK_DELIVERY`; production typically uses `FEEDBACK_DELIVERY=sendmail`. + | Target | Purpose | | --- | --- | | `make frontend-reset` | Remove Vite cache and restart the Docker frontend service. Use after frontend dependency or Vite config changes. | diff --git a/frontend/.react-router/types/+routes.ts b/frontend/.react-router/types/+routes.ts index 6a1feac1..9b73533d 100644 --- a/frontend/.react-router/types/+routes.ts +++ b/frontend/.react-router/types/+routes.ts @@ -14,6 +14,15 @@ type Pages = { "/": { params: {}; }; + "/about": { + params: {}; + }; + "/help": { + params: {}; + }; + "/feedback": { + params: {}; + }; "/search": { params: {}; }; @@ -89,12 +98,24 @@ type Pages = { type RouteFiles = { "root.tsx": { id: "root"; - page: "/" | "/search" | "/resources/:id" | "/resources/:id/static-map" | "/resources/:id/static-map/no-cache" | "/resources/:id/thumbnail" | "/resources/:id/thumbnail/no-cache" | "/thumbnails/placeholder" | "/thumbnails/:image_hash" | "/iiif/manifest" | "/suggest" | "/search/facets/:facetName" | "/map/h3" | "/bookmarks" | "/map" | "/test" | "/test/fixtures" | "/test/fixtures/providers" | "/*"; + page: "/" | "/about" | "/help" | "/feedback" | "/search" | "/resources/:id" | "/resources/:id/static-map" | "/resources/:id/static-map/no-cache" | "/resources/:id/thumbnail" | "/resources/:id/thumbnail/no-cache" | "/thumbnails/placeholder" | "/thumbnails/:image_hash" | "/iiif/manifest" | "/suggest" | "/search/facets/:facetName" | "/map/h3" | "/bookmarks" | "/map" | "/test" | "/test/fixtures" | "/test/fixtures/providers" | "/*"; }; "routes/_index.tsx": { id: "routes/_index"; page: "/"; }; + "routes/about.tsx": { + id: "routes/about"; + page: "/about"; + }; + "routes/help.tsx": { + id: "routes/help"; + page: "/help"; + }; + "routes/feedback.tsx": { + id: "routes/feedback"; + page: "/feedback"; + }; "routes/search.tsx": { id: "routes/search"; page: "/search"; @@ -172,6 +193,9 @@ type RouteFiles = { type RouteModules = { "root": typeof import("./app/root.tsx"); "routes/_index": typeof import("./app/routes/_index.tsx"); + "routes/about": typeof import("./app/routes/about.tsx"); + "routes/help": typeof import("./app/routes/help.tsx"); + "routes/feedback": typeof import("./app/routes/feedback.tsx"); "routes/search": typeof import("./app/routes/search.tsx"); "routes/resources.$id": typeof import("./app/routes/resources.$id.tsx"); "routes/resources.$id.static-map": typeof import("./app/routes/resources.$id.static-map.ts"); @@ -190,4 +214,4 @@ type RouteModules = { "routes/test.fixtures": typeof import("./app/routes/test.fixtures.tsx"); "routes/test.fixtures.providers": typeof import("./app/routes/test.fixtures.providers.tsx"); "routes/$": typeof import("./app/routes/$.tsx"); -}; \ No newline at end of file +}; diff --git a/frontend/app/routes.ts b/frontend/app/routes.ts index ceef5db2..250a0939 100644 --- a/frontend/app/routes.ts +++ b/frontend/app/routes.ts @@ -3,6 +3,9 @@ import { index, route } from "@react-router/dev/routes"; export default [ index("routes/_index.tsx"), + route("about", "routes/about.tsx"), + route("help", "routes/help.tsx"), + route("feedback", "routes/feedback.tsx"), route("search", "routes/search.tsx"), route("search/results", "routes/search.results.ts"), route("resources/:id", "routes/resources.$id.tsx"), diff --git a/frontend/app/routes/__tests__/feedback.test.ts b/frontend/app/routes/__tests__/feedback.test.ts new file mode 100644 index 00000000..172e9f38 --- /dev/null +++ b/frontend/app/routes/__tests__/feedback.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { ActionFunctionArgs } from 'react-router'; +import { action } from '../feedback'; +import { serverFetch } from '../../lib/server-api'; + +vi.mock('../../lib/server-api', () => ({ + serverFetch: vi.fn(), +})); + +function feedbackRequest(fields: Record) { + return new Request('https://geo.example.org/feedback', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + referer: 'https://geo.example.org/feedback', + 'user-agent': 'vitest', + }, + body: new URLSearchParams(fields), + }); +} + +describe('feedback route action', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('posts valid feedback to the API endpoint', async () => { + vi.mocked(serverFetch).mockResolvedValue( + new Response(JSON.stringify({ data: { id: 'submitted' } }), { + status: 202, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + const result = await action({ + request: feedbackRequest({ + name: 'Ada', + email_address: 'ada@example.edu', + topic: 'Question', + description: 'Can someone follow up?', + contact_info: '', + }), + params: {}, + } as unknown as ActionFunctionArgs); + + expect(serverFetch).toHaveBeenCalledTimes(1); + const [endpoint, options] = vi.mocked(serverFetch).mock.calls[0]; + expect(endpoint).toBe('/feedback'); + expect(options?.method).toBe('POST'); + expect(JSON.parse(String(options?.body))).toMatchObject({ + name: 'Ada', + email_address: 'ada@example.edu', + topic: 'Question', + description: 'Can someone follow up?', + source_url: 'https://geo.example.org/feedback', + user_agent: 'vitest', + }); + expect(result).toEqual({ + status: 'success', + message: 'Thank you for your feedback. Your message has been sent.', + }); + }); + + it('returns field errors before posting invalid feedback', async () => { + const result = await action({ + request: feedbackRequest({ + topic: '', + description: '', + }), + params: {}, + } as unknown as ActionFunctionArgs); + + expect(serverFetch).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + status: 'error', + message: 'Please review the highlighted fields.', + fieldErrors: { + topic: 'Select a feedback topic.', + description: 'Enter your feedback.', + }, + }); + }); + + it('returns the API error message when delivery fails', async () => { + vi.mocked(serverFetch).mockResolvedValue( + new Response( + JSON.stringify({ + message: 'Feedback delivery is temporarily unavailable.', + }), + { + status: 503, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + + const result = await action({ + request: feedbackRequest({ + topic: 'Question', + description: 'Can someone follow up?', + }), + params: {}, + } as unknown as ActionFunctionArgs); + + expect(result).toMatchObject({ + status: 'error', + message: 'Feedback delivery is temporarily unavailable.', + }); + }); +}); diff --git a/frontend/app/routes/about.tsx b/frontend/app/routes/about.tsx new file mode 100644 index 00000000..2e3e315a --- /dev/null +++ b/frontend/app/routes/about.tsx @@ -0,0 +1,20 @@ +/* eslint-disable react-refresh/only-export-components */ +import type { LoaderFunctionArgs, MetaFunction } from 'react-router'; +import { AboutPage } from '../../src/pages/AboutPage'; +import { buildSeoMeta } from '../../src/config/seo'; + +export function loader({ request }: LoaderFunctionArgs) { + return { currentUrl: new URL(request.url).href }; +} + +export const meta: MetaFunction = ({ data }) => + buildSeoMeta({ + title: 'About', + description: + 'Learn about the Big Ten Academic Alliance Geoportal and the collections it helps users discover.', + url: data?.currentUrl, + }); + +export default function About() { + return ; +} diff --git a/frontend/app/routes/feedback.tsx b/frontend/app/routes/feedback.tsx new file mode 100644 index 00000000..61a2fcce --- /dev/null +++ b/frontend/app/routes/feedback.tsx @@ -0,0 +1,141 @@ +/* eslint-disable react-refresh/only-export-components */ +import { + useActionData, + useNavigation, + type ActionFunctionArgs, + type LoaderFunctionArgs, + type MetaFunction, +} from 'react-router'; +import { + FeedbackPage, + type FeedbackActionData, +} from '../../src/pages/FeedbackPage'; +import { buildSeoMeta } from '../../src/config/seo'; +import { serverFetch } from '../lib/server-api'; + +const topicOptions = new Set([ + 'Correction', + 'Question', + 'Comments or Suggestions', + 'Harmful language', + 'Other', +]); + +function formString(formData: FormData, name: string) { + const value = formData.get(name); + return typeof value === 'string' ? value.trim() : ''; +} + +function getErrorMessage(payload: unknown) { + if (!payload || typeof payload !== 'object') { + return 'We could not send your feedback. Please try again in a moment.'; + } + + if ('message' in payload && typeof payload.message === 'string') { + return payload.message; + } + + if ('detail' in payload && typeof payload.detail === 'string') { + return payload.detail; + } + + return 'We could not send your feedback. Please try again in a moment.'; +} + +export function loader({ request }: LoaderFunctionArgs) { + return { currentUrl: new URL(request.url).href }; +} + +export async function action({ + request, +}: ActionFunctionArgs): Promise { + const formData = await request.formData(); + const values = { + name: formString(formData, 'name'), + email_address: formString(formData, 'email_address'), + topic: formString(formData, 'topic'), + description: formString(formData, 'description'), + }; + const contactInfo = formString(formData, 'contact_info'); + const fieldErrors: NonNullable< + Extract['fieldErrors'] + > = {}; + + if (!values.topic || !topicOptions.has(values.topic)) { + fieldErrors.topic = 'Select a feedback topic.'; + } + + if (!values.description) { + fieldErrors.description = 'Enter your feedback.'; + } + + if ( + values.email_address && + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email_address) + ) { + fieldErrors.email_address = 'Enter a valid email address.'; + } + + if (Object.keys(fieldErrors).length > 0) { + return { + status: 'error', + message: 'Please review the highlighted fields.', + fieldErrors, + values, + }; + } + + try { + const response = await serverFetch('/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...values, + contact_info: contactInfo, + source_url: request.headers.get('referer') || new URL(request.url).href, + user_agent: request.headers.get('user-agent') || '', + }), + }); + + if (!response.ok) { + const payload = await response.json().catch(() => null); + return { + status: 'error', + message: getErrorMessage(payload), + values, + }; + } + + return { + status: 'success', + message: 'Thank you for your feedback. Your message has been sent.', + }; + } catch (error) { + console.error('Feedback submission failed:', error); + return { + status: 'error', + message: 'We could not send your feedback. Please try again in a moment.', + values, + }; + } +} + +export const meta: MetaFunction = ({ data }) => + buildSeoMeta({ + title: 'Feedback', + description: + 'Send feedback to the Big Ten Academic Alliance Geoportal team.', + url: data?.currentUrl, + }); + +export default function Feedback() { + const actionData = useActionData(); + const navigation = useNavigation(); + + return ( + + ); +} diff --git a/frontend/app/routes/help.tsx b/frontend/app/routes/help.tsx new file mode 100644 index 00000000..d282ff3f --- /dev/null +++ b/frontend/app/routes/help.tsx @@ -0,0 +1,20 @@ +/* eslint-disable react-refresh/only-export-components */ +import type { LoaderFunctionArgs, MetaFunction } from 'react-router'; +import { HelpPage } from '../../src/pages/HelpPage'; +import { buildSeoMeta } from '../../src/config/seo'; + +export function loader({ request }: LoaderFunctionArgs) { + return { currentUrl: new URL(request.url).href }; +} + +export const meta: MetaFunction = ({ data }) => + buildSeoMeta({ + title: 'Help', + description: + 'Learn how to search, filter, view resources, and use bookmarks in the Big Ten Academic Alliance Geoportal.', + url: data?.currentUrl, + }); + +export default function Help() { + return ; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 88c08fb1..8a9d6e70 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,13 +12,16 @@ import { ProviderPillsTestPage } from './pages/ProviderPillsTestPage'; import { MapPage } from './pages/MapPage'; import { TestPage } from './pages/TestPage'; import { NotFoundPage } from './pages/NotFoundPage'; +import { AboutPage } from './pages/AboutPage'; +import { FeedbackPage } from './pages/FeedbackPage'; +import { HelpPage } from './pages/HelpPage'; // Import Leaflet CSS import 'leaflet/dist/leaflet.css'; // Ensure Stimulus is available globally const application = Application.start(); -(window as any).Stimulus = application; +(window as typeof window & { Stimulus: Application }).Stimulus = application; function App() { const [searchParams] = useSearchParams(); @@ -32,6 +35,9 @@ function App() { {/* More specific paths first so /search matches before / */} + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/__tests__/pages/AboutPage.test.tsx b/frontend/src/__tests__/pages/AboutPage.test.tsx new file mode 100644 index 00000000..228fee8b --- /dev/null +++ b/frontend/src/__tests__/pages/AboutPage.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react'; +import { BrowserRouter } from 'react-router'; +import { HelmetProvider } from 'react-helmet-async'; +import { vi } from 'vitest'; +import { AboutPage } from '../../pages/AboutPage'; + +vi.mock('../../components/layout/Header', () => ({ + Header: () =>
    Header
    , +})); + +vi.mock('../../components/layout/Footer', () => ({ + Footer: () =>
    Footer
    , +})); + +describe('AboutPage', () => { + it('renders Geoportal-specific about content and local feedback link', () => { + render( + + + + + + ); + + expect( + screen.getByRole('heading', { name: /about the btaa geoportal/i }) + ).toBeInTheDocument(); + expect( + screen.getByText(/helps users find geospatial resources/i) + ).toBeInTheDocument(); + expect( + screen.getByText(/most resources in the geoportal link to data stored/i) + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/pages/FeedbackPage.test.tsx b/frontend/src/__tests__/pages/FeedbackPage.test.tsx new file mode 100644 index 00000000..46076bed --- /dev/null +++ b/frontend/src/__tests__/pages/FeedbackPage.test.tsx @@ -0,0 +1,85 @@ +import { render, screen } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; +import { HelmetProvider } from 'react-helmet-async'; +import { vi } from 'vitest'; +import { FeedbackPage } from '../../pages/FeedbackPage'; + +vi.mock('../../components/layout/Header', () => ({ + Header: () =>
    Header
    , +})); + +vi.mock('../../components/layout/Footer', () => ({ + Footer: () =>
    Footer
    , +})); + +function renderFeedbackPage(element = ) { + const router = createMemoryRouter([{ path: '/', element }], { + initialEntries: ['/'], + }); + + return render( + + + + ); +} + +describe('FeedbackPage', () => { + it('renders the migrated feedback form fields', () => { + renderFeedbackPage(); + + expect( + screen.getByRole('heading', { name: /feedback/i }) + ).toBeInTheDocument(); + expect(screen.getByLabelText(/name/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/email address/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/topic/i)).toBeRequired(); + expect(screen.getByLabelText(/description/i)).toBeRequired(); + expect( + screen.getByRole('button', { name: /send feedback/i }) + ).toBeInTheDocument(); + }); + + it('renders submission errors and preserves field values', () => { + renderFeedbackPage( + + ); + + expect(screen.getByRole('alert')).toHaveTextContent( + 'Please review the highlighted fields.' + ); + expect(screen.getByLabelText(/email address/i)).toHaveValue('not-an-email'); + expect( + screen.getByText(/enter a valid email address/i) + ).toBeInTheDocument(); + }); + + it('renders a success message', () => { + renderFeedbackPage( + + ); + + expect(screen.getByRole('status')).toHaveTextContent( + 'Thank you for your feedback.' + ); + }); +}); diff --git a/frontend/src/__tests__/pages/HelpPage.test.tsx b/frontend/src/__tests__/pages/HelpPage.test.tsx new file mode 100644 index 00000000..1856c9fd --- /dev/null +++ b/frontend/src/__tests__/pages/HelpPage.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from '@testing-library/react'; +import { BrowserRouter } from 'react-router'; +import { HelmetProvider } from 'react-helmet-async'; +import { vi } from 'vitest'; +import { HelpPage } from '../../pages/HelpPage'; + +vi.mock('../../components/layout/Header', () => ({ + Header: () =>
    Header
    , +})); + +vi.mock('../../components/layout/Footer', () => ({ + Footer: () =>
    Footer
    , +})); + +describe('HelpPage', () => { + it('renders Geoportal help content and local feedback link', () => { + render( + + + + + + ); + + expect( + screen.getByRole('heading', { name: /help/i, level: 1 }) + ).toBeInTheDocument(); + expect( + screen.getByText(/enter keywords in the search box/i) + ).toBeInTheDocument(); + expect( + screen.getByText(/bookmarks are stored in your browser/i) + ).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /contact us/i })).toHaveAttribute( + 'href', + '/feedback' + ); + }); +}); diff --git a/frontend/src/__tests__/pages/Home.test.tsx b/frontend/src/__tests__/pages/Home.test.tsx index bef71957..747c61f3 100644 --- a/frontend/src/__tests__/pages/Home.test.tsx +++ b/frontend/src/__tests__/pages/Home.test.tsx @@ -39,6 +39,14 @@ describe('Home Page', () => { screen.getByRole('heading', { name: /partner institutions/i }) ).toBeInTheDocument(); }); + expect( + screen.getByRole('heading', { + name: /welcome to the new btaa geoportal/i, + }) + ).toBeInTheDocument(); + expect( + screen.getByText(/redesigned search experience/i) + ).toBeInTheDocument(); expect(screen.getByText(/new from btaa:/i)).toBeInTheDocument(); expect( screen.getByRole('link', { name: /read gin news & stories/i }) diff --git a/frontend/src/__tests__/pages/page-titles.test.tsx b/frontend/src/__tests__/pages/page-titles.test.tsx index 878aa8d3..81b8fe17 100644 --- a/frontend/src/__tests__/pages/page-titles.test.tsx +++ b/frontend/src/__tests__/pages/page-titles.test.tsx @@ -20,6 +20,9 @@ import { ResourceView } from '../../pages/ResourceView'; import { BookmarksPage } from '../../pages/BookmarksPage'; import { NotFoundPage } from '../../pages/NotFoundPage'; import { MapPage } from '../../pages/MapPage'; +import { AboutPage } from '../../pages/AboutPage'; +import { HelpPage } from '../../pages/HelpPage'; +import { FeedbackPage } from '../../pages/FeedbackPage'; import { ApiProvider } from '../../context/ApiContext'; import { DebugProvider } from '../../context/DebugContext'; import { BookmarkProvider } from '../../context/BookmarkContext'; @@ -62,7 +65,15 @@ vi.mock('../../pages/MapPage.client', () => { React.createElement( React.Fragment, null, - React.createElement(Helmet, null, React.createElement('title', null, 'Map - Big Ten Academic Alliance Geoportal')), + React.createElement( + Helmet, + null, + React.createElement( + 'title', + null, + 'Map - Big Ten Academic Alliance Geoportal' + ) + ), React.createElement('div', { 'data-testid': 'map-page-client' }, 'Map') ); return { MapPage: MockMapClient, default: MockMapClient }; @@ -183,6 +194,69 @@ describe('Page titles', () => { }); }); + it('AboutPage has a title', async () => { + const routes = [ + { + path: '/about', + element: ( + + + + ), + }, + ]; + const router = createMemoryRouter(routes, { + initialEntries: ['/about'], + }); + render(); + + await waitFor(() => { + assertHasTitle(); + }); + }); + + it('FeedbackPage has a title', async () => { + const routes = [ + { + path: '/feedback', + element: ( + + + + ), + }, + ]; + const router = createMemoryRouter(routes, { + initialEntries: ['/feedback'], + }); + render(); + + await waitFor(() => { + assertHasTitle(); + }); + }); + + it('HelpPage has a title', async () => { + const routes = [ + { + path: '/help', + element: ( + + + + ), + }, + ]; + const router = createMemoryRouter(routes, { + initialEntries: ['/help'], + }); + render(); + + await waitFor(() => { + assertHasTitle(); + }); + }); + it('NotFoundPage has a title', async () => { const routes = [ { diff --git a/frontend/src/components/layout/Footer.client.tsx b/frontend/src/components/layout/Footer.client.tsx index 99b17bd9..6f4389f7 100644 --- a/frontend/src/components/layout/Footer.client.tsx +++ b/frontend/src/components/layout/Footer.client.tsx @@ -30,10 +30,7 @@ function BtaaFooter({ id }: FooterProps) {
    • - + About Us
    • @@ -46,19 +43,13 @@ function BtaaFooter({ id }: FooterProps) {
    • - + Contact Us
    • - - Help Guides + + Help
    • diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 269927e6..21bb6c6e 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -7,11 +7,16 @@ import { useTheme } from '../../hooks/useTheme'; const NAV_LINKS = [ { - href: 'https://gin.btaa.org/about/about-us/', + href: '/about', label: 'About', - external: true, + external: false, }, - { href: 'https://geo.btaa.org/feedback', label: 'Feedback', external: true }, + { + href: '/help', + label: 'Help', + external: false, + }, + { href: '/feedback', label: 'Feedback', external: false }, { href: '/bookmarks', label: 'Bookmarks', external: false }, ]; diff --git a/frontend/src/pages/AboutPage.tsx b/frontend/src/pages/AboutPage.tsx new file mode 100644 index 00000000..565e00ce --- /dev/null +++ b/frontend/src/pages/AboutPage.tsx @@ -0,0 +1,142 @@ +import { ArrowRight } from 'lucide-react'; +import { Link } from 'react-router'; +import { Header } from '../components/layout/Header'; +import { Footer } from '../components/layout/Footer'; +import { Seo } from '../components/Seo'; + +export function AboutPage() { + return ( +
      + +
      +
      +
      +
      +

      + BTAA Geoportal +

      +

      + About the BTAA Geoportal +

      +
      +

      + The Big Ten Academic Alliance (BTAA) Geoportal helps users find + geospatial resources from BTAA member libraries and public data + sources. +

      +

      + The Geoportal brings together maps, geospatial datasets, aerial + imagery, scanned historical maps, web services, and related + documentation so users can search across institutions without + leaving the Geoportal. +

      +

      + Most resources in the Geoportal link to data stored by + libraries, government agencies, and other trusted partners. Some + resources are also stored and shared directly through the + Geoportal as part of a growing BTAA effort to collect and + preserve geospatial data. +

      +
      +
      +
      + +
      +
      +
      +
      +

      + How the Portal Works +

      +

      + The Geoportal indexes descriptive metadata from participating + institutions and presents it through a shared search + interface. When a resource is hosted by a partner institution, + the record links users to the original download, viewer, + service endpoint, or catalog page. +

      +
      +
      +

      + Who Maintains It +

      +

      + The BTAA Geoportal is maintained by the Big Ten Academic + Alliance Geospatial Information Network, a collaborative + program focused on discovery, access, and preservation for + geospatial information. +

      + + Visit BTAA GIN + + +
      +
      +
      +
      + +
      +
      +

      + What You Can Find +

      +
        + {[ + 'GIS datasets', + 'Scanned maps', + 'Historical and public domain maps', + 'Aerial photos', + 'Web mapping services', + 'Interactive maps and websites', + ].map((item) => ( +
      • +
      • + ))} +
      +
      +
      + +
      +
      +
      +

      + Start Exploring +

      +

      + Search the full Geoportal collection or send feedback to the + project team. +

      +
      +
      + + Browse resources + + + + Share feedback + +
      +
      +
      +
      +
      +
      + ); +} diff --git a/frontend/src/pages/ErrorPage.tsx b/frontend/src/pages/ErrorPage.tsx index 546c9aed..8c23c6f0 100644 --- a/frontend/src/pages/ErrorPage.tsx +++ b/frontend/src/pages/ErrorPage.tsx @@ -78,12 +78,12 @@ export function GeoportalErrorPage({ > Search - Feedback - + @@ -144,13 +144,10 @@ export function GeoportalErrorPage({ Geoportal home - + Contact us - + diff --git a/frontend/src/pages/FeedbackPage.tsx b/frontend/src/pages/FeedbackPage.tsx new file mode 100644 index 00000000..ed129114 --- /dev/null +++ b/frontend/src/pages/FeedbackPage.tsx @@ -0,0 +1,233 @@ +import { Send } from 'lucide-react'; +import { Form } from 'react-router'; +import { Header } from '../components/layout/Header'; +import { Footer } from '../components/layout/Footer'; +import { Seo } from '../components/Seo'; + +export type FeedbackFormValues = { + name: string; + email_address: string; + topic: string; + description: string; +}; + +export type FeedbackFieldErrors = Partial< + Record +>; + +export type FeedbackActionData = + | { + status: 'success'; + message: string; + } + | { + status: 'error'; + message: string; + fieldErrors?: FeedbackFieldErrors; + values?: FeedbackFormValues; + }; + +const topicOptions = [ + 'Correction', + 'Question', + 'Comments or Suggestions', + 'Harmful language', + 'Other', +]; + +const inputClass = + 'mt-2 block w-full border border-gray-300 bg-white px-3 py-2 text-base text-gray-900 shadow-sm focus:border-brand-active focus:outline-none focus:ring-2 focus:ring-brand-active/30'; +const labelClass = 'block text-sm font-semibold text-gray-900'; + +function fieldError( + actionData: FeedbackActionData | undefined, + fieldName: keyof FeedbackFormValues +) { + return actionData?.status === 'error' + ? actionData.fieldErrors?.[fieldName] + : undefined; +} + +function fieldValue( + actionData: FeedbackActionData | undefined, + fieldName: keyof FeedbackFormValues +) { + return actionData?.status === 'error' + ? actionData.values?.[fieldName] || '' + : ''; +} + +export function FeedbackPage({ + actionData, + isSubmitting = false, +}: { + actionData?: FeedbackActionData; + isSubmitting?: boolean; +}) { + const success = actionData?.status === 'success'; + const formKey = success ? 'feedback-sent' : 'feedback-editing'; + + return ( +
      + +
      +
      +
      +
      +

      + Contact Us +

      +

      + Feedback +

      +

      + We value your thoughts and opinions. We will reply to all comments + shortly. +

      +
      +
      + +
      +
      + {actionData && ( +
      + {actionData.message} +
      + )} + +
      +
      + + About You + +
      + + + {fieldError(actionData, 'name') && ( +

      + {fieldError(actionData, 'name')} +

      + )} +
      + +
      + + + {fieldError(actionData, 'email_address') && ( +

      + {fieldError(actionData, 'email_address')} +

      + )} +
      +
      + +
      + + Leave Your Feedback + +
      + + + {fieldError(actionData, 'topic') && ( +

      + {fieldError(actionData, 'topic')} +

      + )} +
      + +
      + +