+
+
© {OPERATIONS.establishedYear}–2026 Sea Saba, NV • The Bottom, Saba, Caribbean Netherlands
diff --git a/components/partners/conservation-partners-section.tsx b/components/partners/conservation-partners-section.tsx
index 2904fff..6382804 100644
--- a/components/partners/conservation-partners-section.tsx
+++ b/components/partners/conservation-partners-section.tsx
@@ -44,6 +44,17 @@ export function ConservationPartnersSection({ partners }: ConservationPartnersSe
))}
+
+
+ Want to support the island's conservation and community work?{" "}
+
+ See ways to give back to Saba
+
+ .
+
);
}
diff --git a/data/community-support.ts b/data/community-support.ts
new file mode 100644
index 0000000..315b1f0
--- /dev/null
+++ b/data/community-support.ts
@@ -0,0 +1,152 @@
+/**
+ * Sea Saba community-giving program — the "Request Support from Sea Saba"
+ * half of /donate (#171). The visitor-donation registry stays in
+ * data/donations.ts; this file is the other direction: local organizations
+ * and project leads asking Sea Saba for help.
+ *
+ * CONTENT STATUS: DRAFT pending owner review. Categories, support types,
+ * standards, and process wording below are a proposed starting point — edit
+ * freely. The page renders straight from this file, so wording changes never
+ * touch JSX. Do not add dollar caps, annual budgets, deadlines, guaranteed
+ * funding, or response-time promises unless the owner supplies them.
+ */
+
+/** Request categories offered in the form and shown on the page. */
+export type SupportRequestCategory =
+ | "community"
+ | "youth"
+ | "education"
+ | "conservation"
+ | "animal-welfare"
+ | "sports"
+ | "culture"
+ | "events"
+ | "other";
+
+export interface SupportRequestCategoryOption {
+ value: SupportRequestCategory;
+ label: string;
+}
+
+/**
+ * UX groupings, not funding promises — listing a category here does not mean
+ * every request in it is approved.
+ */
+export const SUPPORT_REQUEST_CATEGORIES: readonly SupportRequestCategoryOption[] = [
+ { value: "community", label: "Community" },
+ { value: "youth", label: "Youth" },
+ { value: "education", label: "Education" },
+ { value: "conservation", label: "Conservation" },
+ { value: "animal-welfare", label: "Animal welfare" },
+ { value: "sports", label: "Sports" },
+ { value: "culture", label: "Culture" },
+ { value: "events", label: "Events" },
+ { value: "other", label: "Other" },
+];
+
+export function supportRequestCategoryLabel(value: string): string {
+ return (
+ SUPPORT_REQUEST_CATEGORIES.find((c) => c.value === value)?.label ?? value
+ );
+}
+
+/** The kinds of help Sea Saba may provide — not only money. */
+export type SupportType =
+ | "financial"
+ | "goods"
+ | "prize"
+ | "services"
+ | "staff-time"
+ | "in-kind";
+
+export interface SupportTypeOption {
+ value: SupportType;
+ label: string;
+ /** One line shown on the page under "What support can look like". */
+ description: string;
+}
+
+export const SUPPORT_TYPES: readonly SupportTypeOption[] = [
+ { value: "financial", label: "Financial support or sponsorship", description: "A contribution toward a specific, documented cost, or sponsorship of a legitimate team, event, project, or initiative." },
+ { value: "goods", label: "Goods or supplies", description: "Equipment, materials, or supplies purchased by Sea Saba." },
+ { value: "prize", label: "Prize or raffle contribution", description: "A dive, course, or item donated as a prize." },
+ { value: "services", label: "Sea Saba services", description: "Diving, boat time, courses, or use of our facilities." },
+ { value: "staff-time", label: "Staff or community participation", description: "Hands-on help from our team at an event or project." },
+ { value: "in-kind", label: "Other in-kind help", description: "Anything else useful that isn't cash." },
+];
+
+export function supportTypeLabels(values: readonly string[]): string[] {
+ return values.map(
+ (v) => SUPPORT_TYPES.find((t) => t.value === v)?.label ?? v
+ );
+}
+
+/**
+ * Who may ask. Rendered as a short bulleted list — the first half of the
+ * page's request-side content.
+ */
+export const WHO_CAN_REQUEST: readonly string[] = [
+ "Saba community organizations, clubs, and associations",
+ "Schools, youth programs, and sports teams",
+ "Conservation and animal-welfare initiatives on the island",
+ "Local events, cultural projects, and fundraisers",
+ "Individuals leading an identifiable community project",
+];
+
+export interface SupportStandard {
+ title: string;
+ description: string;
+}
+
+/**
+ * The ground rules. Positive framing on purpose: these exist so Sea Saba can
+ * keep saying yes to good projects responsibly — they are not accusations.
+ * Order matters; the page renders them in this order.
+ */
+export const SUPPORT_STANDARDS: readonly SupportStandard[] = [
+ {
+ title: "A clear benefit to Saba",
+ description:
+ "The request should benefit the island: its people, environment, youth, culture, events, or community organizations.",
+ },
+ {
+ title: "A clear project and someone responsible",
+ description:
+ "Tell us what you're doing, who benefits, what you're asking for, how the support will be used, when it happens, and who we can contact about it.",
+ },
+ {
+ title: "We can see where support goes",
+ description:
+ "For monetary requests we need to understand where the money goes. Depending on the request we may ask for a budget, a quote or invoice, organization details, or proof the project or event exists, proportionate to what's being asked.",
+ },
+ {
+ title: "Projects, not personal cash gifts",
+ description:
+ "We generally don't provide unrestricted cash gifts to individuals for personal use. Individuals are welcome to lead community projects. We just need to see the project and how the support will be used.",
+ },
+ {
+ title: "Direct support where it's cleaner",
+ description:
+ "Where it makes accountability easier, we prefer to pay a supplier directly, purchase the requested goods ourselves, provide Sea Saba services, or sponsor the documented project rather than hand over cash.",
+ },
+ {
+ title: "A quick follow-up afterward",
+ description:
+ "We may ask for a receipt, a photo, or a short update once support is used. Small requests, small follow-ups.",
+ },
+];
+
+/** How a request moves through the program. */
+export const SUPPORT_REQUEST_STEPS: readonly string[] = [
+ "Send your request using the form below. Email and WhatsApp work too if that's easier.",
+ "We read every request against the ground rules and may come back with a follow-up question or two.",
+ "If it's a fit, we coordinate the support, often directly with a supplier or organizer.",
+ "Afterward we may ask for a quick confirmation: a receipt, a photo, or a short update.",
+];
+
+/**
+ * Honest, non-legalistic note shown near the form. Do not soften into a
+ * promise, and do not add quotas or deadlines without owner approval.
+ */
+export const SUPPORT_REQUEST_NO_GUARANTEE =
+ "We'd love to say yes to everything, but we can't. Submitting a request doesn't guarantee funding or support. If we're unable to help, we'll still try to point you in a useful direction.";
diff --git a/data/donations.ts b/data/donations.ts
new file mode 100644
index 0000000..fb747d7
--- /dev/null
+++ b/data/donations.ts
@@ -0,0 +1,109 @@
+/**
+ * Registry of the Saba organizations and causes featured on /donate (#171).
+ *
+ * STATUS: two recipients populated from organization-supplied material
+ * (Sea & Learn Foundation and Saba Conservation Foundation, September 2026).
+ * Both organizations asked to review the final rendered card wording before
+ * publication, so this registry must not go live until the owner confirms
+ * each organization has signed off. Until then, entries may be edited in
+ * place — do not add placeholder, sample, or unverified entries to make the
+ * list look complete, and do not add pending recipients early.
+ *
+ * To add a recipient, append one entry here — no page or layout changes are
+ * needed. Every entry must be owner-confirmed: name, description, and website
+ * come from the organization itself, and `donationUrl` (optional) must be the
+ * recipient's own donation page. Sea Saba never collects, processes, or
+ * retains donations, so there is no checkout or payment field here by design.
+ *
+ * Fields that must never be guessed: nonprofit/tax status, percentages or
+ * fee breakdowns, and any charitable claim. Leave `funds` unset unless the
+ * organization itself states what a gift supports.
+ */
+export type DonationCategory =
+ | "conservation"
+ | "community"
+ | "animals"
+ | "youth"
+ | "culture"
+ | "marine-conservation"
+ | "science-education";
+
+interface DonationRecipientBase {
+ /** Organization or cause name, as the organization itself styles it. */
+ name: string;
+ /** One or two sentences on who they are and what they do on Saba. */
+ description: string;
+ /** Plain-language summary of what a donation helps fund — from the recipient's own materials only. */
+ funds?: string;
+ /** The organization's own website — required; doubles as the fallback destination when no donationUrl exists. */
+ website: string;
+ /**
+ * Direct donation page on the recipient's own site, when one exists.
+ * First-party recipient URLs only — never an intermediary and never a
+ * Sea Saba checkout (none exists; do not build one). Store the canonical
+ * page URL bare, without tracking parameters.
+ */
+ donationUrl?: string;
+ /** Optional grouping label shown as a small pill on the card. */
+ category?: DonationCategory;
+}
+
+/**
+ * Card logo pairing: `image` and `imageAlt` are all-or-nothing. Making the
+ * alt text a required field alongside `image` keeps it an explicit
+ * editorial/accessibility decision — a future entry cannot compile with a
+ * logo and no deliberate alt text, and the renderer never invents one.
+ */
+type DonationRecipientImage =
+ | {
+ /** Approved local asset under public/images — never a hotlinked third-party logo. */
+ image: string;
+ /** Alt text for `image`; required when `image` is set. */
+ imageAlt: string;
+ }
+ | {
+ image?: undefined;
+ imageAlt?: undefined;
+ };
+
+export type DonationRecipient = DonationRecipientBase & DonationRecipientImage;
+
+export const DONATION_RECIPIENTS: DonationRecipient[] = [
+ {
+ // Sea & Learn Foundation: a year-round foundation, not just the October
+ // event. Copy below condenses their supplied description; final wording
+ // pending their review. Card uses their supplied horizontal transparent
+ // logo; the vertical variant stays in public/images/optimized as an
+ // alternate.
+ name: "Sea & Learn Foundation",
+ description:
+ "A year-round foundation running hands-on science, environmental education, youth development, and cultural heritage programs that connect Saba's community with the island's extraordinary natural and cultural resources.",
+ funds:
+ "Year-round programs on Saba, or a specific Sea & Learn Foundation project the donor chooses.",
+ website: "https://www.seaandlearn.org/",
+ donationUrl: "https://www.seaandlearn.org/donate",
+ category: "science-education",
+ image: "/images/optimized/sea-and-learn-foundation-logo-horizontal.webp",
+ imageAlt: "Sea & Learn Foundation logo",
+ },
+ {
+ // Saba Conservation Foundation: description covers their full mission
+ // on land and at sea — both national parks — per their review request;
+ // the "helps fund" line keeps the visitor-donation emphasis on the
+ // National Marine Park and coral restoration. Donations go to their
+ // own donate page (the direct PayPal URL they also supplied is
+ // intentionally not used). Card uses their supplied full-color logo on
+ // the light card; the white variant stays in public/images/optimized
+ // as an alternate for dark backgrounds.
+ name: "Saba Conservation Foundation",
+ description:
+ "The foundation works to protect and restore Saba's natural environment on land and at sea by managing the Saba National Marine Park and Mt. Scenery National Park.",
+ funds:
+ "Conservation of Saba's National Marine Park and ongoing projects such as coral restoration.",
+ website: "https://sabapark.org/saba-conservation-foundation/",
+ donationUrl: "https://sabapark.org/saba-conservation-foundation/donate-support/",
+ category: "marine-conservation",
+ image: "/images/optimized/saba-conservation-foundation-logo-color.webp",
+ imageAlt: "Saba Conservation Foundation logo",
+ },
+];
diff --git a/docs/ANALYTICS_SEO.md b/docs/ANALYTICS_SEO.md
index 46427b6..d00c6e8 100644
--- a/docs/ANALYTICS_SEO.md
+++ b/docs/ANALYTICS_SEO.md
@@ -36,6 +36,9 @@
| `phone_click` | Footer and contact page | Page parameters, link parameters, and legacy aliases |
| `whatsapp_click` | Footer and contact form/page | Page parameters, sanitized link parameters, and legacy aliases |
| `directions_click` | FindSeaSaba map card/tooltip | Page parameters, link parameters, and legacy aliases |
+| `donation_click` | `/donate` recipient "Donate directly" CTAs — outbound links to each organization's own donation page | Page parameters, link parameters, and legacy aliases |
+| `donation_request_started` | `/donate` "Request Support" form — first field focus | Page parameters and `button_location` only — **never** names, emails, request contents, amounts, or any field values |
+| `donation_request_submitted` | `/donate` "Request Support" form — a completed form continuing to the visitor's email app or WhatsApp (`method` distinguishes them). A handoff, not a confirmed send | Page parameters, `method`, `button_location` only — **never** request details |
| `ferry_link_click` | Plan-your-trip and local partner ferry links | Page parameters, link parameters, and legacy aliases |
| `social_click` | Social, partner, accommodation, and outbound resource links | Page parameters, link parameters, `partner_name` where applicable, and legacy aliases |
| `pdf_download` | Dive-log PDF export | Page parameters, `dive_count`, `unit_system` |
@@ -69,8 +72,8 @@ diagnostic-only variables) and what each one does.
`app/sitemap.ts` lists every canonical public route — `/`, `/diving`,
`/dive-sites`, `/book`, `/plan-your-trip`, `/courses`, `/dive-log`,
-`/visiting-yachts`, `/about`, `/contact`, `/partners`, `/terms`, `/privacy`,
-`/cookie-policy` — driven by the `SITEMAP_ROUTES` table. It deliberately
+`/visiting-yachts`, `/about`, `/contact`, `/partners`, `/donate`, `/terms`,
+`/privacy`, `/cookie-policy` — driven by the `SITEMAP_ROUTES` table. It deliberately
omits `lastModified`: no trustworthy content-modification date exists, and a
build-time timestamp is a fake freshness signal crawlers discount. Add new
public pages to the table; `tests/unit/seo.test.ts` guards the list.
diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md
index 3f1bbcf..872b290 100644
--- a/docs/OPERATIONS.md
+++ b/docs/OPERATIONS.md
@@ -14,6 +14,9 @@ keeps its own wording and context.
| `lib/contact.ts` | Contact-form field limits and the inquiry email builders (subject, structured body, `mailto:` URI). The form opens the visitor's own email app addressed to `CONTACT.email` — server-side sending was removed because a common `From` address collapsed all visitors into one Respond.io contact. Direct Respond.io ingestion is deferred pending a plan upgrade; see issue #104. WhatsApp stays a client-side handoff |
| `lib/respond-io.ts` + `components/respond-io-widget.tsx` | Website Chat channel — the native Respond.io launcher (channel `451154`, public widget cId via `NEXT_PUBLIC_RESPOND_IO_CID`) injected after window load. Widget appearance, pre-chat fields, popup message and workflow routing are Respond.io dashboard settings, not website code. Per-visitor identity is handled inside the widget's own iframe. Launcher position: dashboard "Vertical/Horizontal Spacing" (single value for all viewports — effective offset is 25px + the setting). `app/globals.css` additionally translates the closed launcher `translate(36px, 36px)` and clips its transparent hit area — scoped to `data-launcher-only`, which `respond-io-widget.tsx` sets only while the iframe measures launcher-sized (90x90); the promotional prompt reuses the same `state="widgetClose"` iframe at ~330x179, so keying off `state` alone would clip the prompt. The ~57px visible bubble sits ~5px inside the 90x90 iframe's corner, so the vendor offset left ~53-54px of visible clearance vs Cookiebot's ~10-11px; the shift lands the bubble ~17-18px from the right/bottom edges. On the homepage the closed launcher (and greeting popup, same iframe) is hidden while the `[data-hero]` hero region is in view — `respond-io-widget.tsx` toggles `data-hero-in-view` on `` via IntersectionObserver and `app/globals.css` hides `iframe[state="widgetClose"]`; an open conversation (`widgetOpen`) is never hidden, and other routes show the launcher normally |
| `lib/anchors.ts` | Route section ids shared by pages and `data/redirects.ts` |
+| `data/donations.ts` | `DONATION_RECIPIENTS` — owner-approved organizations/causes shown on `/donate` (currently Sea & Learn Foundation and Saba Conservation Foundation, card wording pending each organization's review before publication; the page still renders an in-progress state if the registry is emptied). Entries link out to each organization's own site — Sea Saba never processes donations |
+| `data/community-support.ts` | Community-giving program content for `/donate` — who may request support from Sea Saba, request categories, support types, review standards, and process steps. DRAFT wording pending owner review; edit there, not in the page. No dollar caps, budgets, deadlines, or funding guarantees without owner approval |
+| `lib/support-request.ts` | Support-request field limits, validation, and the email/WhatsApp handoff builders (`mailto:` / `wa.me`). Same visitor-sends-it-themselves mechanism as `lib/contact.ts` — do not reintroduce a shared-sender server endpoint (see issue #104) |
| `app/(en)/(content)/terms/page.tsx` | Cancellation, reschedule, refund, weather, and charter policy — the canonical legal terms |
| Checkfront | Live availability, bookable inventory, transactional pricing. The site intentionally shows no prices; `/book` links into Checkfront |
diff --git a/lib/analytics.ts b/lib/analytics.ts
index ff58610..f034fe2 100644
--- a/lib/analytics.ts
+++ b/lib/analytics.ts
@@ -12,6 +12,9 @@ export type AnalyticsEvent =
| "phone_click"
| "whatsapp_click"
| "directions_click"
+ | "donation_click"
+ | "donation_request_started"
+ | "donation_request_submitted"
| "ferry_link_click"
| "social_click"
| "pdf_download"
diff --git a/lib/support-request.ts b/lib/support-request.ts
new file mode 100644
index 0000000..dd15a74
--- /dev/null
+++ b/lib/support-request.ts
@@ -0,0 +1,209 @@
+import { CONTACT } from "@/lib/constants";
+import {
+ supportRequestCategoryLabel,
+ supportTypeLabels,
+} from "@/data/community-support";
+
+/**
+ * Client-email handoff for the /donate "Request Support from Sea Saba" form —
+ * the same submission mechanism as the contact form. The site builds a
+ * pre-populated `mailto:` draft (or a WhatsApp message); the visitor's own
+ * email app sends it, so Respond.io sees the visitor's real From address and
+ * the request lands on the correct contact. Server-side sending was removed
+ * site-wide because a common `From` address collapsed all visitors into one
+ * Respond.io contact — see issue #104 for the deferred Custom Channel
+ * architecture. Do not reintroduce a shared-sender server endpoint here.
+ *
+ * Pure functions — no Next.js or provider imports — so the submission
+ * contract is unit-testable in isolation.
+ */
+
+export const SUPPORT_REQUEST_LIMITS = {
+ name: 100,
+ organization: 150,
+ email: 254,
+ phone: 40,
+ amount: 80,
+ request: 400,
+ description: 1500,
+ beneficiaries: 300,
+ timing: 150,
+ useOfSupport: 500,
+ referenceUrl: 300,
+} as const;
+
+/** Everything needed to evaluate and deliver a community-support request. */
+export interface SupportRequestDraft {
+ name: string;
+ /** Organization, group, or project name — optional for individuals. */
+ organization: string;
+ email: string;
+ /** Optional phone or WhatsApp number for follow-up questions. */
+ phone: string;
+ /** `SUPPORT_REQUEST_CATEGORIES` slug. */
+ category: string;
+ /** `SUPPORT_TYPES` slugs — one or more kinds of help being asked for. */
+ supportTypes: readonly string[];
+ /**
+ * Estimated amount or value, required only when "financial" is among
+ * `supportTypes` — a money request without a number can't be evaluated.
+ */
+ amount: string;
+ /** What is being requested, in the requester's own words. */
+ request: string;
+ /** What the project or event is. */
+ description: string;
+ /** Who benefits. */
+ beneficiaries: string;
+ /** When the project or event happens / the timeline. */
+ timing: string;
+ /** How Sea Saba's contribution would be used. */
+ useOfSupport: string;
+ /**
+ * Whether Sea Saba may pay a supplier/vendor directly or purchase goods
+ * instead of handing over cash — the accountability-friendly option the
+ * program prefers.
+ */
+ vendorPayment: boolean;
+ /** Optional link to project/event information. */
+ referenceUrl: string;
+ /**
+ * Required acknowledgement: the information is accurate and Sea Saba may
+ * request supporting information before deciding.
+ */
+ acknowledged: boolean;
+}
+
+export type SupportRequestErrors = Partial
>;
+
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+/**
+ * Client-side validation shared by the form and tests. Returns a map of
+ * field → message; empty means submittable. This is the same trust level as
+ * the contact form — the email is reviewed and sent by the visitor, and Sea
+ * Saba staff evaluate the content, so validation exists to produce a
+ * complete request, not to gatekeep.
+ */
+export function validateSupportRequest(
+ draft: SupportRequestDraft
+): SupportRequestErrors {
+ const errors: SupportRequestErrors = {};
+ if (!draft.name.trim()) errors.name = "Please enter your name.";
+ if (!draft.email.trim()) {
+ errors.email = "Please enter your email address.";
+ } else if (!EMAIL_RE.test(draft.email.trim())) {
+ errors.email = "Please enter a valid email address.";
+ }
+ if (!draft.category) errors.category = "Please choose a category.";
+ if (draft.supportTypes.length === 0)
+ errors.supportTypes = "Please choose at least one type of support.";
+ if (draft.supportTypes.includes("financial") && !draft.amount.trim())
+ errors.amount =
+ "Please give an estimated amount so we can evaluate the request.";
+ if (!draft.request.trim())
+ errors.request = "Please tell us what you're asking Sea Saba for.";
+ if (!draft.description.trim())
+ errors.description = "Please describe the project, event, or cause.";
+ if (!draft.beneficiaries.trim())
+ errors.beneficiaries = "Please tell us who benefits.";
+ if (!draft.timing.trim())
+ errors.timing = "Please tell us when it happens or your timeline.";
+ if (!draft.useOfSupport.trim())
+ errors.useOfSupport = "Please tell us how our contribution would be used.";
+ if (draft.referenceUrl.trim() && !/^https?:\/\/\S+$/.test(draft.referenceUrl.trim()))
+ errors.referenceUrl = "Please enter a full URL starting with http:// or https://.";
+ if (!draft.acknowledged)
+ errors.acknowledged = "Please confirm the information is accurate before sending.";
+ return errors;
+}
+
+/** Single-line fields must not carry CR/LF into headers or label lines. */
+const stripNewlines = (s: string) => s.replace(/[\r\n]+/g, " ").trim();
+
+/** Canonical subject — searchable in the inbox alongside other inquiries. */
+export function supportRequestSubject(draft: SupportRequestDraft): string {
+ const who =
+ stripNewlines(draft.organization) || stripNewlines(draft.name) || "";
+ return ("Community Support Request" + (who ? `: ${who}` : "")).slice(0, 200);
+}
+
+/**
+ * Human-readable structured body. Every labeled line is a field Sea Saba
+ * uses to evaluate the request; optional fields appear only when filled so
+ * the request reads cleanly.
+ */
+export function supportRequestEmailBody(draft: SupportRequestDraft): string {
+ const line = (label: string, value: string) =>
+ `${label}: ${stripNewlines(value)}`;
+
+ const lines = [
+ "Sea Saba Community Support Request",
+ "",
+ line("Name", draft.name),
+ line("Email", draft.email),
+ ];
+ if (draft.organization.trim())
+ lines.push(line("Organization / group / project", draft.organization));
+ if (draft.phone.trim()) lines.push(line("Phone / WhatsApp", draft.phone));
+ lines.push(
+ line("Category", supportRequestCategoryLabel(draft.category)),
+ line("Type of support requested", supportTypeLabels(draft.supportTypes).join(", "))
+ );
+ if (draft.amount.trim())
+ lines.push(line("Estimated amount or value", draft.amount));
+ lines.push(
+ "",
+ "What we're asking for:",
+ draft.request.trim(),
+ "",
+ "About the project or event:",
+ draft.description.trim(),
+ "",
+ line("Who benefits", draft.beneficiaries),
+ line("When / timeline", draft.timing),
+ "",
+ "How Sea Saba's contribution would be used:",
+ draft.useOfSupport.trim()
+ );
+ lines.push(
+ "",
+ `Open to Sea Saba paying a supplier directly or purchasing goods: ${
+ draft.vendorPayment ? "Yes" : "No"
+ }`
+ );
+ if (draft.referenceUrl.trim())
+ lines.push(line("Link to project/event info", draft.referenceUrl));
+ lines.push(
+ "",
+ "The requester confirmed the information above is accurate and understands Sea Saba may ask for supporting information before deciding."
+ );
+ return lines.join("\n");
+}
+
+/**
+ * `mailto:` URI for the draft. Recipient is always the canonical inbox;
+ * subject/body are URI-encoded (line breaks normalized to CRLF, the most
+ * broadly accepted mailto newline form). Because the visitor's own email
+ * app sends it, Respond.io files the request under the visitor's real
+ * address — not a shared website sender.
+ */
+export function buildSupportRequestMailto(draft: SupportRequestDraft): string {
+ const subject = encodeURIComponent(supportRequestSubject(draft));
+ const body = encodeURIComponent(
+ supportRequestEmailBody(draft).replace(/\n/g, "\r\n")
+ );
+ return `mailto:${CONTACT.email}?subject=${subject}&body=${body}`;
+}
+
+/**
+ * WhatsApp handoff — the same structured request as a text message. WhatsApp
+ * is how much of Saba communicates; the message lands in the same Respond.io
+ * inbox under the sender's own number.
+ */
+export function buildSupportRequestWhatsAppUrl(
+ draft: SupportRequestDraft
+): string {
+ const text = `Hi Sea Saba, I'd like to request community support.\n\n${supportRequestEmailBody(draft)}`;
+ return `https://wa.me/${CONTACT.whatsappNumber}?text=${encodeURIComponent(text)}`;
+}
diff --git a/public/images/optimized/saba-conservation-foundation-logo-color.webp b/public/images/optimized/saba-conservation-foundation-logo-color.webp
new file mode 100644
index 0000000..844a0e5
Binary files /dev/null and b/public/images/optimized/saba-conservation-foundation-logo-color.webp differ
diff --git a/public/images/optimized/saba-conservation-foundation-logo-white.webp b/public/images/optimized/saba-conservation-foundation-logo-white.webp
new file mode 100644
index 0000000..a9465c0
Binary files /dev/null and b/public/images/optimized/saba-conservation-foundation-logo-white.webp differ
diff --git a/public/images/optimized/sea-and-learn-foundation-logo-horizontal.webp b/public/images/optimized/sea-and-learn-foundation-logo-horizontal.webp
new file mode 100644
index 0000000..b863459
Binary files /dev/null and b/public/images/optimized/sea-and-learn-foundation-logo-horizontal.webp differ
diff --git a/public/images/optimized/sea-and-learn-foundation-logo-vertical.webp b/public/images/optimized/sea-and-learn-foundation-logo-vertical.webp
new file mode 100644
index 0000000..7c8b60f
Binary files /dev/null and b/public/images/optimized/sea-and-learn-foundation-logo-vertical.webp differ
diff --git a/public/llms.txt b/public/llms.txt
index b5d2689..1adc969 100644
--- a/public/llms.txt
+++ b/public/llms.txt
@@ -19,6 +19,7 @@ informational pages are server-rendered HTML and readable without JavaScript.
- [Contact](https://www.seasaba.com/contact): inquiries, bookings, WhatsApp, and location
- [Book](https://www.seasaba.com/book): online reservation for dives, courses, and trips
- [Recommended Partners](https://www.seasaba.com/partners): accommodations, restaurants, transportation, and local services
+- [Support Saba](https://www.seasaba.com/donate): Sea Saba's community-giving page, covering ways for visitors to support island organizations directly and how Saba groups can request a donation or sponsorship from Sea Saba
- [Sea Saba Dive Log](https://www.seasaba.com/dive-log): recent dives and sightings from Sea Saba boats
## Policies
diff --git a/tests/e2e/accessibility.spec.ts b/tests/e2e/accessibility.spec.ts
index a8fc5e2..90b09de 100644
--- a/tests/e2e/accessibility.spec.ts
+++ b/tests/e2e/accessibility.spec.ts
@@ -30,9 +30,10 @@ const PUBLIC_PAGES = [
{ path: "/dive-sites", name: "dive sites" },
{ path: "/about", name: "about" },
{ path: "/dive-log", name: "dive log" },
+ { path: "/donate", name: "support saba" },
];
-const MOBILE_SCAN_PAGES = ["/", "/contact", "/book"];
+const MOBILE_SCAN_PAGES = ["/", "/contact", "/book", "/donate"];
test.describe("automated axe scans", () => {
for (const { path, name } of PUBLIC_PAGES) {
@@ -72,6 +73,16 @@ test.describe("automated axe scans", () => {
await expectNoAxeViolations(page);
});
+ test("donate request form with validation errors shown has no accessibility violations", async ({ page, isMobile, browserName }) => {
+ test.skip(isMobile || browserName !== "chromium", "representative scan on desktop-chromium");
+ await hydratedGoto(page, "/donate", "#sr-name");
+ await page.getByRole("button", { name: "Continue to email" }).click();
+ // Error text is always mounted but `invisible` until the field is touched.
+ await expect(page.locator("#sr-name-error")).not.toHaveClass(/invisible/);
+ await expect(page.locator("#sr-email-error")).not.toHaveClass(/invisible/);
+ await expectNoAxeViolations(page);
+ });
+
test("dive site dialog has no accessibility violations", async ({ page, isMobile, browserName }) => {
test.skip(isMobile || browserName !== "chromium", "representative scan on desktop-chromium");
await hydratedGoto(page, "/dive-sites");
diff --git a/tests/e2e/link-integrity.spec.ts b/tests/e2e/link-integrity.spec.ts
index 74aaa8f..63df91d 100644
--- a/tests/e2e/link-integrity.spec.ts
+++ b/tests/e2e/link-integrity.spec.ts
@@ -8,7 +8,7 @@ import { test, expect } from "./fixtures";
const PUBLIC_PAGES = [
"/", "/diving", "/dive-sites", "/courses", "/plan-your-trip",
"/visiting-yachts", "/about", "/contact", "/book", "/dive-log",
- "/partners", "/terms", "/privacy", "/cookie-policy",
+ "/partners", "/donate", "/terms", "/privacy", "/cookie-policy",
];
// Static assets and framework URLs that legitimately appear as href/src values.
diff --git a/tests/e2e/locale-routing.spec.ts b/tests/e2e/locale-routing.spec.ts
index 2676c99..701eafa 100644
--- a/tests/e2e/locale-routing.spec.ts
+++ b/tests/e2e/locale-routing.spec.ts
@@ -11,6 +11,7 @@ const PUBLIC_ROUTES = [
"/book",
"/dive-log",
"/partners",
+ "/donate",
"/terms",
"/privacy",
"/cookie-policy",
diff --git a/tests/e2e/resize-scroll.spec.ts b/tests/e2e/resize-scroll.spec.ts
index 2f1db79..37641c1 100644
--- a/tests/e2e/resize-scroll.spec.ts
+++ b/tests/e2e/resize-scroll.spec.ts
@@ -172,8 +172,6 @@ const BOTTOM_TOLERANCE = 24;
// The keeper restores a landmark's gap below the chrome exactly; this covers
// font-load shifts and sub-pixel rounding in re-measurement.
const LANDMARK_TOLERANCE = 60;
-// Well clear of the footer region (footer is ~460px tall at desktop widths).
-const FOOTER_THRESHOLD_HINT = 1500;
// UX budget for transient landmark drift during a stepped resize. The
// keeper pins per frame, so the only legitimate movement is sticky-chrome
// growth (~34px for a wrapped pill nav) plus a frame of jitter. The previous
@@ -323,12 +321,13 @@ test.describe("bottom preservation", () => {
// The same element is still at the top of the reading area — the scroll
// offset may move by the full reflow delta, but the visible content must
- // not change. And the visitor was not pulled toward the bottom.
+ // not change. And the visitor was not pulled toward the bottom — the
+ // footer itself stays below the fold.
expect(after.landmarkTop).not.toBeNull();
expect(
Math.abs((after.landmarkGap ?? 0) - (before.landmarkGap ?? 0))
).toBeLessThanOrEqual(LANDMARK_TOLERANCE);
- expect(geo.fromBottom).toBeGreaterThan(FOOTER_THRESHOLD_HINT);
+ expect(geo.footerVisible).toBe(false);
});
});
@@ -467,28 +466,39 @@ test.describe("content landmark preservation", () => {
// because the threshold was measured against the taller post-reflow footer.
test.describe("homepage landmark preservation", () => {
// Reading positions covering a heading, the responsive image-card grid,
- // and content just above the footer (577px from the end at 1280 — inside
- // the post-reflow 758px footer but outside the 462px pre-reflow one).
- // `frac` is a fraction of max scroll; negative means px above the bottom.
- const positions = [
+ // and content just above the footer. `frac` is a fraction of the max
+ // scroll; negative means px above the bottom. The footer position instead
+ // anchors to the footer's top edge — the semantic boundary — so the
+ // scenario does not depend on how tall the footer happens to be.
+ const positions: { label: string; frac?: number; aboveFooterPx?: number }[] = [
{ label: "upper sections", frac: 0.25 },
{ label: "image-card grid", frac: 0.66 },
- { label: "just above footer", frac: -577 },
+ { label: "just above footer", aboveFooterPx: 160 },
];
- for (const { label, frac } of positions) {
- test(`keeps ${label} in view 1280→768`, async ({ page, isMobile }) => {
+ for (const position of positions) {
+ test(`keeps ${position.label} in view 1280→768`, async ({ page, isMobile }) => {
test.skip(!!isMobile, "desktop window-resize scenario");
await page.setViewportSize({ width: 1280, height: 800 });
await hydratedGoto(page, "/");
- await page.evaluate((f) => {
- const max =
- document.documentElement.scrollHeight - window.innerHeight;
- window.scrollTo(0, f >= 0 ? max * f : max + f);
- }, frac);
+ await page.evaluate((p) => {
+ const doc = document.documentElement;
+ const max = doc.scrollHeight - window.innerHeight;
+ if (p.aboveFooterPx != null) {
+ const footer = document.querySelector("footer");
+ const top = footer
+ ? footer.getBoundingClientRect().top + window.scrollY
+ : doc.scrollHeight;
+ window.scrollTo(0, top - window.innerHeight - p.aboveFooterPx);
+ } else {
+ const frac = p.frac ?? 0;
+ window.scrollTo(0, frac >= 0 ? max * frac : max + frac);
+ }
+ }, position);
await waitForStableScroll(page);
const before = await tagLandmark(page);
+ const beforeGeo = await measure(page);
expect(before.landmarkTop).not.toBeNull();
await page.setViewportSize({ width: 768, height: 800 });
@@ -502,7 +512,13 @@ test.describe("homepage landmark preservation", () => {
Math.abs((after.landmarkGap ?? 0) - (before.landmarkGap ?? 0))
).toBeLessThanOrEqual(LANDMARK_TOLERANCE);
const geo = await measure(page);
- expect(geo.fromBottom).toBeGreaterThan(FOOTER_THRESHOLD_HINT);
+ // A narrowing reflow can only lengthen the document, so a preserved
+ // reading position leaves the page end at least as far below the fold
+ // as before — a keeper that mis-pinned the visitor toward the bottom
+ // would shrink this distance. And the footer itself stays out of view:
+ // the visitor is reading content above it, not viewing the footer.
+ expect(geo.fromBottom).toBeGreaterThanOrEqual(beforeGeo.fromBottom);
+ expect(geo.footerVisible).toBe(false);
});
}
diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts
index b52ff18..1a41915 100644
--- a/tests/e2e/smoke.spec.ts
+++ b/tests/e2e/smoke.spec.ts
@@ -1,7 +1,7 @@
import { test, expect } from "./fixtures";
import { legacyRedirects } from "../../data/redirects";
-const routes = ["/", "/diving", "/dive-sites", "/courses", "/plan-your-trip", "/about", "/contact", "/book", "/dive-log", "/partners", "/terms", "/privacy", "/cookie-policy"];
+const routes = ["/", "/diving", "/dive-sites", "/courses", "/plan-your-trip", "/about", "/contact", "/book", "/dive-log", "/partners", "/donate", "/terms", "/privacy", "/cookie-policy"];
for (const path of routes) {
test(`@smoke public page ${path} renders without authentication`, async ({ page }) => {
const response = await page.goto(path);
diff --git a/tests/integration/donate.test.tsx b/tests/integration/donate.test.tsx
new file mode 100644
index 0000000..c967457
--- /dev/null
+++ b/tests/integration/donate.test.tsx
@@ -0,0 +1,573 @@
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { track } from "@vercel/analytics";
+import DonatePage, { metadata } from "@/app/(en)/(content)/donate/page";
+import { DonationsSection } from "@/components/donations/donations-section";
+import { SupportRequestForm } from "@/components/donations/support-request-form";
+import { DONATION_RECIPIENTS, type DonationRecipient } from "@/data/donations";
+import {
+ SUPPORT_REQUEST_CATEGORIES,
+ SUPPORT_TYPES,
+ SUPPORT_STANDARDS,
+ SUPPORT_REQUEST_STEPS,
+ WHO_CAN_REQUEST,
+ SUPPORT_REQUEST_NO_GUARANTEE,
+} from "@/data/community-support";
+import { divingAnchors } from "@/lib/anchors";
+
+// /donate (issue #171) serves two audiences: visitors looking for vetted Saba
+// organizations to support directly (Sea Saba never processes donations — the
+// registry currently holds Sea & Learn Foundation and Saba Conservation
+// Foundation from organization-supplied material, with final card wording
+// pending each organization's review), and Saba organizations/projects
+// requesting support FROM Sea Saba (the community-giving program). Request
+// submissions use the same visitor-handoff mechanism as the contact form —
+// the requester's own email app or WhatsApp sends it, so no server endpoint,
+// no shared-sender Respond.io collapse (#104).
+
+const FIXTURE: DonationRecipient[] = [
+ {
+ name: "Example Reef Fund",
+ description: "Protects the reefs around the island.",
+ funds: "Moorings, patrols, and reef monitoring",
+ website: "https://reef.example.org",
+ donationUrl: "https://reef.example.org/give",
+ category: "conservation",
+ },
+ {
+ name: "Example Community Project",
+ description: "Community programs in the villages.",
+ website: "https://community.example.org",
+ },
+];
+
+// Compile-time contract (PR #172 review): `image` is assignable only
+// alongside a required `imageAlt`, so alt text stays an explicit registry
+// decision — never missing, never invented by the renderer. `npm run
+// typecheck` is the guard: if the union ever loosens, the @ts-expect-error
+// below fails as an unused directive.
+const IMAGE_CONTRACT_FIXTURES: DonationRecipient[] = [
+ {
+ name: "Example No-Logo Project",
+ description: "Recipient without a card image.",
+ website: "https://nologo.example.org",
+ },
+ {
+ name: "Example Logo Project",
+ description: "Recipient whose card image carries deliberate alt text.",
+ website: "https://logo.example.org",
+ image: "/images/example-logo.webp",
+ imageAlt: "Example Logo Project mark",
+ },
+];
+
+// @ts-expect-error — `image` requires `imageAlt`.
+const IMAGE_WITHOUT_ALT: DonationRecipient = {
+ name: "Example Missing-Alt Project",
+ description: "Registry entry that must not compile.",
+ website: "https://missing-alt.example.org",
+ image: "/images/example-logo.webp",
+};
+
+describe("donate page", () => {
+ it("renders a single h1, the intro, and the trust note", () => {
+ render( );
+ expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
+ expect(
+ screen.getByRole("heading", { level: 1, name: /support saba/i })
+ ).toBeTruthy();
+ expect(
+ screen.getByText(/never collects, processes, or retains donated funds/i)
+ ).toBeTruthy();
+ });
+
+ it("represents both audiences: giving visitors and island requesters", () => {
+ render( );
+ expect(
+ screen.getByRole("heading", { level: 2, name: /ways to give back/i })
+ ).toBeTruthy();
+ expect(
+ screen.getByRole("heading", { level: 2, name: /request support from sea saba/i })
+ ).toBeTruthy();
+ // The intro signposts island requesters to the request section.
+ const signpost = screen.getByRole("link", {
+ name: /request support from sea saba/i,
+ });
+ expect(signpost.getAttribute("href")).toBe("/donate#request-support");
+ expect(document.getElementById("request-support")).toBeTruthy();
+ });
+
+ it("states the existing marine-park contribution and deep-links to it", () => {
+ render( );
+ const link = screen.getByRole("link", {
+ name: /how the saba marine park works/i,
+ });
+ expect(link.getAttribute("href")).toBe(`/diving#${divingAnchors.marinePark}`);
+ });
+
+ it("renders the draft standards, eligibility, support types, and process from data", () => {
+ render( );
+ for (const item of WHO_CAN_REQUEST) expect(screen.getByText(item)).toBeTruthy();
+ for (const type of SUPPORT_TYPES) {
+ expect(screen.getAllByText(type.label).length).toBeGreaterThan(0);
+ }
+ for (const standard of SUPPORT_STANDARDS) {
+ expect(screen.getByText(standard.title)).toBeTruthy();
+ expect(screen.getByText(standard.description)).toBeTruthy();
+ }
+ for (const step of SUPPORT_REQUEST_STEPS) {
+ expect(screen.getByText(step)).toBeTruthy();
+ }
+ expect(screen.getByText(SUPPORT_REQUEST_NO_GUARANTEE)).toBeTruthy();
+ });
+
+ it("renders the organization-confirmed recipients and no pending ones", () => {
+ render( );
+ expect(
+ screen.getByRole("heading", { name: "Sea & Learn Foundation" })
+ ).toBeTruthy();
+ expect(
+ screen.getByRole("heading", { name: "Saba Conservation Foundation" })
+ ).toBeTruthy();
+ // Recipients still in outreach must not appear: Saba Reach Foundation,
+ // Child Focus Foundation, Body Mind & Spirit, SFPCA (deferred), and the
+ // unapproved Project Bureau Saba / Sea Fan Society Foundation concept.
+ for (const pending of [
+ /saba reach/i,
+ /child focus/i,
+ /body,?\s*mind/i,
+ /sfpca|prevention of cruelty/i,
+ /sea fan/i,
+ /project bureau/i,
+ ]) {
+ expect(screen.queryByText(pending)).toBeNull();
+ }
+ });
+
+ it("keeps claims honest — no checkout, funding promises, or tax language", () => {
+ const { container } = render( );
+ const text = container.textContent ?? "";
+ expect(text).not.toMatch(/checkout|add to cart|processing fee/i);
+ // No tax-deductibility claim without owner-supplied documentation.
+ expect(text).not.toMatch(/tax[- ]deductible|501\(c\)/i);
+ // No invented funding promises or approval timelines. (Real dollar
+ // figures do appear — the owner-confirmed marine-park conservation fees.)
+ expect(text).not.toMatch(/guaranteed (funding|support|approval)/i);
+ expect(text).not.toMatch(/within \d+ (business )?(days|hours|weeks)/i);
+ });
+
+ it("uses no em or en dashes in customer-facing copy", () => {
+ // Owner style rule: no em dashes in website copy. Rendering the whole
+ // page covers page prose, community-support policy data, form labels and
+ // helper text, and the recipients empty state in one check.
+ const { container } = render( );
+ expect(container.textContent ?? "").not.toMatch(/[—–]/);
+ expect(String(metadata.description ?? "")).not.toMatch(/[—–]/);
+ });
+
+ it("has canonical metadata naming the program for both audiences", () => {
+ expect(metadata.title).toBe("Support Saba");
+ expect(metadata.alternates?.canonical).toBe("https://www.seasaba.com/donate");
+ const description = String(metadata.description ?? "");
+ expect(description).toMatch(/support saba/i);
+ expect(description).toMatch(/request/i);
+ });
+});
+
+describe("community-support policy data (DRAFT)", () => {
+ it("ships the promised categories as a typed list", () => {
+ const labels = SUPPORT_REQUEST_CATEGORIES.map((c) => c.label);
+ for (const label of [
+ "Community",
+ "Youth",
+ "Education",
+ "Conservation",
+ "Animal welfare",
+ "Sports",
+ "Culture",
+ "Events",
+ "Other",
+ ]) {
+ expect(labels).toContain(label);
+ }
+ });
+
+ it("supports non-monetary help, not just money", () => {
+ const values = SUPPORT_TYPES.map((t) => t.value);
+ for (const v of ["financial", "goods", "services", "in-kind"]) {
+ expect(values).toContain(v);
+ }
+ for (const t of SUPPORT_TYPES) {
+ expect(t.label.trim()).not.toBe("");
+ expect(t.description.trim()).not.toBe("");
+ }
+ });
+
+ it("ships six standards and six support types — the desktop grids are full", () => {
+ // Both card grids render sm:grid-cols-2; six entries fill three rows with
+ // no orphan card. Guard the count so a later addition doesn't reintroduce
+ // the dangling seventh card.
+ expect(SUPPORT_STANDARDS).toHaveLength(6);
+ expect(SUPPORT_TYPES).toHaveLength(6);
+ // The merged money option keeps the "financial" slug: the amount field's
+ // required-when-financial gate and existing drafts depend on it.
+ expect(SUPPORT_TYPES.find((t) => t.value === "financial")?.label).toBe(
+ "Financial support or sponsorship"
+ );
+ });
+
+ it("never contains invented guarantees, amounts, deadlines, or tax claims", () => {
+ const copy = [
+ ...SUPPORT_STANDARDS.flatMap((s) => [s.title, s.description]),
+ ...SUPPORT_TYPES.flatMap((t) => [t.label, t.description]),
+ ...SUPPORT_REQUEST_STEPS,
+ ...WHO_CAN_REQUEST,
+ SUPPORT_REQUEST_NO_GUARANTEE,
+ ].join(" ");
+ expect(copy).not.toMatch(/\$\s?\d|USD\s?\d/i);
+ expect(copy).not.toMatch(/guaranteed (funding|support|approval)/i);
+ expect(copy).not.toMatch(/tax[- ]deductible|nonprofit|501\(c\)/i);
+ expect(copy).not.toMatch(/within \d+ (business )?(days|hours|weeks)/i);
+ // The no-guarantee note itself must be honest, not a hidden promise.
+ expect(SUPPORT_REQUEST_NO_GUARANTEE).toMatch(/doesn.t guarantee/i);
+ });
+});
+
+describe("donation recipient cards", () => {
+ it("renders name, category, description, and funding line per entry", () => {
+ render( );
+ expect(
+ screen.getByRole("heading", { name: "Example Reef Fund" })
+ ).toBeTruthy();
+ expect(screen.getByText("Conservation")).toBeTruthy();
+ expect(screen.getByText(/protects the reefs/i)).toBeTruthy();
+ expect(screen.getByText(/moorings, patrols/i)).toBeTruthy();
+ expect(
+ screen.getByRole("heading", { name: "Example Community Project" })
+ ).toBeTruthy();
+ });
+
+ it("donation CTAs open the recipient's own site in a new tab with descriptive labels", () => {
+ render( );
+ const donate = screen.getByRole("link", { name: /donate directly to example reef fund/i });
+ expect(donate.getAttribute("href")).toBe("https://reef.example.org/give");
+ expect(donate.getAttribute("target")).toBe("_blank");
+ expect(donate.getAttribute("rel")).toContain("noopener");
+ expect(donate.getAttribute("rel")).toContain("noreferrer");
+ expect(donate.getAttribute("aria-label")).toMatch(/opens in a new tab/i);
+
+ const visit = screen.getByRole("link", { name: /visit example reef fund's website/i });
+ expect(visit.getAttribute("href")).toBe("https://reef.example.org");
+ });
+
+ it("falls back to a website-only CTA when no donationUrl exists", () => {
+ render( );
+ const card = screen
+ .getByRole("heading", { name: "Example Community Project" })
+ .closest("article")!;
+ const scoped = within(card as HTMLElement);
+ expect(scoped.queryByRole("link", { name: /donate/i })).toBeNull();
+ const visit = scoped.getByRole("link", { name: /visit.*website/i });
+ expect(visit.getAttribute("href")).toBe("https://community.example.org");
+ expect(visit.getAttribute("target")).toBe("_blank");
+ });
+
+ it("renders the supplied image alt text verbatim — never a generated fallback", () => {
+ render( );
+ expect(screen.getByAltText("Example Logo Project mark")).toBeTruthy();
+ const noLogoCard = screen
+ .getByRole("heading", { name: "Example No-Logo Project" })
+ .closest("article")!;
+ expect(within(noLogoCard as HTMLElement).queryByRole("img")).toBeNull();
+ // Keeps the negative fixture live; the real check is `npm run typecheck`.
+ expect(IMAGE_WITHOUT_ALT.image).toBe("/images/example-logo.webp");
+ });
+
+ it("still renders the graceful in-progress state when the registry is empty", () => {
+ render( );
+ expect(
+ screen.getByText(/assembling a short list of saba organizations/i)
+ ).toBeTruthy();
+ // The empty state still offers a real next step.
+ expect(screen.getByRole("link", { name: /^ask us$/i }).getAttribute("href")).toBe(
+ "/contact"
+ );
+ });
+});
+
+describe("donation recipient registry (#171)", () => {
+ // Both entries below come from the organizations' own September 2026
+ // outreach replies; final card wording is pending their review before
+ // publication.
+ it("holds exactly the two organization-confirmed recipients", () => {
+ expect(DONATION_RECIPIENTS).toHaveLength(2);
+ expect(DONATION_RECIPIENTS.map((r) => r.name)).toEqual([
+ "Sea & Learn Foundation",
+ "Saba Conservation Foundation",
+ ]);
+ });
+
+ it("gives Sea & Learn its canonical donation URL, free of tracking params", () => {
+ const recipient = DONATION_RECIPIENTS.find(
+ (r) => r.name === "Sea & Learn Foundation"
+ );
+ expect(recipient?.donationUrl).toBe("https://www.seaandlearn.org/donate");
+ expect(recipient?.donationUrl).not.toMatch(/[?#]/);
+ // Year-round framing: the card must not read as the October event.
+ expect(recipient?.description).toMatch(/year-round/i);
+ expect(`${recipient?.description} ${recipient?.funds ?? ""}`).not.toMatch(
+ /october|annual event/i
+ );
+ });
+
+ it("gives SCF its preferred donation page — never the raw PayPal URL", () => {
+ const recipient = DONATION_RECIPIENTS.find(
+ (r) => r.name === "Saba Conservation Foundation"
+ );
+ expect(recipient?.donationUrl).toBe(
+ "https://sabapark.org/saba-conservation-foundation/donate-support/"
+ );
+ // SCF supplied a direct PayPal link but asked us not to use it; PayPal
+ // must not appear anywhere in the registry.
+ expect(JSON.stringify(DONATION_RECIPIENTS)).not.toMatch(/paypal/i);
+ // Their requested emphasis: the marine park and coral restoration.
+ expect(`${recipient?.description} ${recipient?.funds ?? ""}`).toMatch(
+ /national marine park/i
+ );
+ expect(`${recipient?.description} ${recipient?.funds ?? ""}`).toMatch(
+ /coral restoration/i
+ );
+ });
+
+ it("describes SCF's mission on land and at sea — both national parks", () => {
+ const recipient = DONATION_RECIPIENTS.find(
+ (r) => r.name === "Saba Conservation Foundation"
+ );
+ // SCF's review request: the description carries the full mission
+ // (Saba National Marine Park AND Mt. Scenery National Park), while
+ // the funding line keeps the visitor-donation marine emphasis.
+ expect(recipient?.description).toMatch(/on land and at sea/i);
+ expect(recipient?.description).toMatch(/saba national marine park/i);
+ expect(recipient?.description).toMatch(/mt\.? scenery national park/i);
+ expect(recipient?.funds).toMatch(/national marine park/i);
+ expect(recipient?.funds).toMatch(/coral restoration/i);
+ expect(recipient?.category).toBe("marine-conservation");
+ });
+
+ it("sends every donation CTA straight to the recipient's own site", () => {
+ render( );
+ for (const r of DONATION_RECIPIENTS) {
+ const cta = screen.getByRole("link", {
+ name: new RegExp(`donate directly to ${r.name}`, "i"),
+ });
+ expect(cta.getAttribute("href")).toBe(r.donationUrl);
+ expect(cta.getAttribute("target")).toBe("_blank");
+ // First-party recipient domain only — Sea Saba never touches the gift.
+ expect(new URL(r.donationUrl!).hostname).not.toContain("seasaba.com");
+ }
+ });
+
+ it("keeps every entry inside the content contract", () => {
+ expect(Array.isArray(DONATION_RECIPIENTS)).toBe(true);
+ for (const r of DONATION_RECIPIENTS) {
+ expect(r.name.trim()).not.toBe("");
+ expect(r.description.trim()).not.toBe("");
+ expect(r.website).toMatch(/^https:\/\//);
+ if (r.donationUrl) expect(r.donationUrl).toMatch(/^https:\/\//);
+ if (r.image) {
+ // Logos are organization-supplied local assets under public/images —
+ // never a hotlinked third-party URL, and the file must actually ship.
+ expect(r.imageAlt?.trim()).toBeTruthy();
+ expect(r.image).toMatch(/^\/images\//);
+ expect(
+ existsSync(join(process.cwd(), "public", r.image)),
+ `${r.name} image ${r.image}`
+ ).toBe(true);
+ }
+ // No invented charitable claims in organization-facing copy.
+ expect(`${r.description} ${r.funds ?? ""}`).not.toMatch(
+ /tax[- ]deductible|nonprofit|501\(c\)/i
+ );
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Request Support form — same visitor-handoff contract as the contact form.
+// Uses the real analytics plumbing (@vercel/analytics track is mocked in
+// setup.ts; GTM pushes go to window.dataLayer) so the PII assertions exercise
+// the actual sanitization path, not a mock of it.
+// ---------------------------------------------------------------------------
+
+async function fillValidRequest() {
+ await userEvent.type(screen.getByRole("textbox", { name: /your name/i }), "Sentinel Person");
+ await userEvent.type(
+ screen.getByRole("textbox", { name: /organization, group, or project/i }),
+ "Sentinel Youth Club"
+ );
+ await userEvent.type(screen.getByRole("textbox", { name: /^email/i }), "sentinel@example.test");
+ await userEvent.selectOptions(screen.getByRole("combobox", { name: /category/i }), "youth");
+ await userEvent.click(screen.getByRole("checkbox", { name: /financial support or sponsorship/i }));
+ await userEvent.click(screen.getByRole("checkbox", { name: /goods or supplies/i }));
+ await userEvent.type(
+ screen.getByRole("textbox", { name: /estimated amount or value/i }),
+ "USD 9001"
+ );
+ await userEvent.type(
+ screen.getByRole("textbox", { name: /what are you asking sea saba for/i }),
+ "Sentinel request detail"
+ );
+ await userEvent.type(
+ screen.getByRole("textbox", { name: /about the project or event/i }),
+ "Sentinel project description"
+ );
+ await userEvent.type(
+ screen.getByRole("textbox", { name: /who benefits/i }),
+ "Sentinel beneficiaries"
+ );
+ await userEvent.type(
+ screen.getByRole("textbox", { name: /when is it happening/i }),
+ "Sentinel timing"
+ );
+ await userEvent.type(
+ screen.getByRole("textbox", { name: /contribution be used/i }),
+ "Sentinel use"
+ );
+ await userEvent.click(
+ screen.getByRole("checkbox", { name: /information above is accurate/i })
+ );
+}
+
+describe("support request form", () => {
+ beforeEach(() => {
+ vi.spyOn(window, "open").mockReturnValue(null);
+ (window as unknown as { dataLayer: unknown[] }).dataLayer = [];
+ });
+
+ it("blocks an empty submission with accessible errors, including the acknowledgement", async () => {
+ render( );
+ await userEvent.click(screen.getByRole("button", { name: "Continue to email" }));
+ for (const message of [
+ "Please enter your name.",
+ "Please enter your email address.",
+ "Please choose a category.",
+ "Please choose at least one type of support.",
+ "Please tell us what you're asking Sea Saba for.",
+ "Please describe the project, event, or cause.",
+ "Please tell us who benefits.",
+ "Please tell us when it happens or your timeline.",
+ "Please tell us how our contribution would be used.",
+ "Please confirm the information is accurate before sending.",
+ ]) {
+ expect(screen.getByText(message)).toBeVisible();
+ }
+ expect(screen.getByRole("textbox", { name: /your name/i })).toHaveAttribute(
+ "aria-invalid",
+ "true"
+ );
+ expect(window.open).not.toHaveBeenCalled();
+ });
+
+ it("requires an estimated amount only when financial support is selected", async () => {
+ render( );
+ await userEvent.click(screen.getByRole("checkbox", { name: /financial support or sponsorship/i }));
+ await userEvent.click(screen.getByRole("button", { name: "Continue to email" }));
+ expect(
+ screen.getByText(/please give an estimated amount/i)
+ ).toBeVisible();
+ });
+
+ // fillValidRequest() types through ~10 real fields via userEvent — easily
+ // 3-4s on a fast machine and past the 5s default under full-suite load.
+ // A timed-out test can strand queued input events that corrupt the next
+ // test's render, so the submission tests get explicit headroom.
+ it("opens the requester's own email app with the structured request", { timeout: 20000 }, async () => {
+ render( );
+ await fillValidRequest();
+ await userEvent.click(screen.getByRole("button", { name: "Continue to email" }));
+
+ const href = String(vi.mocked(window.open).mock.calls.at(-1)?.[0]);
+ const url = new URL(href);
+ expect(`${url.protocol}${url.pathname}`).toBe("mailto:info@seasaba.com");
+ expect(url.searchParams.get("subject")).toContain("Sentinel Youth Club");
+ const body = url.searchParams.get("body") ?? "";
+ expect(body).toContain("sentinel@example.test");
+ expect(body).toContain("Financial support or sponsorship, Goods or supplies");
+ expect(body).toContain("paying a supplier directly or purchasing goods: No");
+ // The handoff notice offers a retry path.
+ expect(screen.getByRole("status").textContent).toMatch(/email app should open/i);
+ });
+
+ it("records the direct-payment preference when checked", { timeout: 20000 }, async () => {
+ render( );
+ await fillValidRequest();
+ await userEvent.click(
+ screen.getByRole("checkbox", { name: /pay a supplier directly/i })
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Continue to email" }));
+ const href = String(vi.mocked(window.open).mock.calls.at(-1)?.[0]);
+ expect(new URL(href).searchParams.get("body")).toContain(
+ "paying a supplier directly or purchasing goods: Yes"
+ );
+ });
+
+ it("offers a WhatsApp handoff carrying the same request", { timeout: 20000 }, async () => {
+ render( );
+ await fillValidRequest();
+ await userEvent.click(
+ screen.getByRole("button", { name: "Send request by WhatsApp" })
+ );
+ const href = String(vi.mocked(window.open).mock.calls.at(-1)?.[0]);
+ expect(href).toContain("https://wa.me/");
+ expect(decodeURIComponent(href)).toContain("Sentinel request detail");
+ });
+
+ it("fires donation_request_started exactly once, before submission", async () => {
+ render( );
+ const name = screen.getByRole("textbox", { name: /your name/i });
+ await userEvent.click(name);
+ await userEvent.tab();
+ await userEvent.click(name);
+ const started = vi
+ .mocked(track)
+ .mock.calls.filter(([event]) => event === "donation_request_started");
+ expect(started).toHaveLength(1);
+ });
+
+ it("never sends request details or PII to analytics", { timeout: 20000 }, async () => {
+ render( );
+ await fillValidRequest();
+ await userEvent.click(screen.getByRole("button", { name: "Continue to email" }));
+ await userEvent.click(
+ screen.getByRole("button", { name: "Send request by WhatsApp" })
+ );
+
+ const trackCalls = vi.mocked(track).mock.calls;
+ expect(
+ trackCalls.filter(([event]) => event === "donation_request_submitted")
+ ).toHaveLength(2);
+
+ // Every analytics payload — Vercel track() calls and GTM dataLayer pushes —
+ // serialized and scanned for anything a requester typed.
+ const dataLayer = (window as unknown as { dataLayer?: unknown[] }).dataLayer ?? [];
+ const payloads = [
+ ...trackCalls.map((c) => JSON.stringify(c)),
+ ...dataLayer.map((e) => JSON.stringify(e)),
+ ];
+ for (const pii of [
+ "Sentinel Person",
+ "sentinel@example.test",
+ "Sentinel Youth Club",
+ "Sentinel request detail",
+ "Sentinel project description",
+ "9001",
+ ]) {
+ for (const payload of payloads) {
+ expect(payload, `analytics leaked ${pii}`).not.toContain(pii);
+ }
+ }
+ });
+});
diff --git a/tests/integration/internal-links.test.tsx b/tests/integration/internal-links.test.tsx
index 7fb0406..1c50134 100644
--- a/tests/integration/internal-links.test.tsx
+++ b/tests/integration/internal-links.test.tsx
@@ -8,6 +8,7 @@ import PlanYourTripPage from "@/app/(en)/(content)/plan-your-trip/page";
import VisitingYachtsPage from "@/app/(en)/(content)/visiting-yachts/page";
import TermsPage from "@/app/(en)/(content)/terms/page";
import PartnersPage from "@/app/(en)/(content)/partners/page";
+import DonatePage from "@/app/(en)/(content)/donate/page";
import { Footer } from "@/components/footer";
import {
coursesAnchors,
@@ -76,6 +77,7 @@ describe("canonical anchors resolve to real section ids", () => {
// /diving anchors are already covered by operations-sourcing.test.tsx.
const idCheckCases: [string, React.ReactElement][] = [
["/", ],
+ ["/donate", ],
...cases.map(([path, element]) => [path, element] as [string, React.ReactElement]),
];
@@ -154,6 +156,73 @@ describe("specific-purpose links carry canonical fragments", () => {
}
});
+ it("donate entry points resolve: footer, partners, and plan-your-trip", () => {
+ render();
+ // Support Saba lives in the Plan Your Trip column (owner IA review):
+ // exactly one footer link to /donate, under the trip-planning nav.
+ const links = screen.getAllByRole("link", { name: "Support Saba" });
+ expect(links).toHaveLength(1);
+ expect(links[0].getAttribute("href")).toBe("/donate");
+ const planNav = screen.getByRole("navigation", {
+ name: /trip planning links/i,
+ });
+ expect(planNav.contains(links[0])).toBe(true);
+ });
+
+ it("the footer Plan and Explore columns balance at seven links each", () => {
+ render();
+ const planNav = screen.getByRole("navigation", {
+ name: /trip planning links/i,
+ });
+ const exploreNav = screen.getByRole("navigation", {
+ name: /site navigation/i,
+ });
+ expect(planNav.querySelectorAll("a")).toHaveLength(7);
+ expect(exploreNav.querySelectorAll("a")).toHaveLength(7);
+ });
+
+ it("the footer Explore column links to the yacht guide", () => {
+ render();
+ const nav = screen.getByRole("navigation", { name: /site navigation/i });
+ const link = Array.from(nav.querySelectorAll("a")).find(
+ (a) => a.textContent === "Visiting Yachts"
+ );
+ expect(link?.getAttribute("href")).toBe("/visiting-yachts");
+ });
+
+ it("partners conservation section and plan-your-trip island section link to /donate", () => {
+ const { container: partners } = render( );
+ const conservation = partners.querySelector(`#${partnersAnchors.conservationPartners}`);
+ expect(
+ Array.from(conservation?.querySelectorAll("a") ?? []).some(
+ (a) => a.getAttribute("href") === "/donate"
+ ),
+ "missing /donate link in partners conservation section"
+ ).toBe(true);
+
+ const { container: pyt } = render( );
+ const history = pyt.querySelector(`#${planYourTripAnchors.history}`);
+ expect(
+ Array.from(history?.querySelectorAll("a") ?? []).some(
+ (a) => a.getAttribute("href") === "/donate"
+ ),
+ "missing /donate link in plan-your-trip island section"
+ ).toBe(true);
+ });
+
+ it("the donate page's internal links resolve", () => {
+ render( );
+ expect(
+ hrefOf(/how the saba marine park works/i)
+ ).toBe(`/diving#${divingAnchors.marinePark}`);
+ // The intro signposts island organizations to the request half of the
+ // page. (The empty-registry "ask us" -> /contact link is covered by the
+ // DonationsSection fail-safe test in donate.test.tsx.)
+ expect(hrefOf(/^request support from sea saba$/i)).toBe(
+ "/donate#request-support"
+ );
+ });
+
it("where-to-stay on plan-your-trip offers a contact path", () => {
const { container } = render( );
const section = container.querySelector(`#${planYourTripAnchors.whereToStay}`);
diff --git a/tests/unit/seo.test.ts b/tests/unit/seo.test.ts
index 7c41236..8933f2a 100644
--- a/tests/unit/seo.test.ts
+++ b/tests/unit/seo.test.ts
@@ -18,6 +18,7 @@ const CANONICAL_ROUTES = [
"/about",
"/contact",
"/partners",
+ "/donate",
"/terms",
"/privacy",
"/cookie-policy",
diff --git a/tests/unit/support-request.test.ts b/tests/unit/support-request.test.ts
new file mode 100644
index 0000000..6ea1334
--- /dev/null
+++ b/tests/unit/support-request.test.ts
@@ -0,0 +1,199 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildSupportRequestMailto,
+ buildSupportRequestWhatsAppUrl,
+ supportRequestEmailBody,
+ supportRequestSubject,
+ validateSupportRequest,
+ SUPPORT_REQUEST_LIMITS,
+ type SupportRequestDraft,
+} from "@/lib/support-request";
+import { CONTACT } from "@/lib/constants";
+
+// Submission contract for /donate's "Request Support from Sea Saba" form
+// (#171). Same visitor-handoff mechanism as lib/contact.ts: the visitor's own
+// email app or WhatsApp sends the request, so Respond.io files it under their
+// real address — no shared-sender collapse, no server endpoint.
+
+const VALID: SupportRequestDraft = {
+ name: "Alice Johnson",
+ organization: "Saba Youth Football",
+ email: "alice@example.test",
+ phone: "+599 416 0000",
+ category: "youth",
+ supportTypes: ["financial", "goods"],
+ amount: "USD 500",
+ request: "Sponsorship of team uniforms",
+ description: "Our under-14 team needs new uniforms for the season.",
+ beneficiaries: "About 30 kids aged 8-14",
+ timing: "October 2026",
+ useOfSupport: "Uniforms and league fees — quote available",
+ vendorPayment: true,
+ referenceUrl: "https://example.test/team",
+ acknowledged: true,
+};
+
+describe("validateSupportRequest", () => {
+ it("accepts a complete draft", () => {
+ expect(validateSupportRequest(VALID)).toEqual({});
+ });
+
+ it("requires name, email, category, and the core explanation fields", () => {
+ const errors = validateSupportRequest({
+ ...VALID,
+ name: "",
+ email: "",
+ category: "",
+ supportTypes: [],
+ request: "",
+ description: "",
+ beneficiaries: "",
+ timing: "",
+ useOfSupport: "",
+ });
+ for (const field of [
+ "name",
+ "email",
+ "category",
+ "supportTypes",
+ "request",
+ "description",
+ "beneficiaries",
+ "timing",
+ "useOfSupport",
+ ]) {
+ expect(errors[field], `expected an error for ${field}`).toBeTruthy();
+ }
+ });
+
+ it("rejects malformed email but not missing phone/organization", () => {
+ const errors = validateSupportRequest({
+ ...VALID,
+ email: "not-an-email",
+ phone: "",
+ organization: "",
+ });
+ expect(errors.email).toBeTruthy();
+ expect(errors.phone).toBeUndefined();
+ expect(errors.organization).toBeUndefined();
+ });
+
+ it("requires an estimated amount only for financial requests", () => {
+ expect(
+ validateSupportRequest({ ...VALID, supportTypes: ["services"], amount: "" }).amount
+ ).toBeUndefined();
+ expect(
+ validateSupportRequest({ ...VALID, supportTypes: ["financial"], amount: "" }).amount
+ ).toBeTruthy();
+ });
+
+ it("requires the accuracy acknowledgement", () => {
+ const errors = validateSupportRequest({ ...VALID, acknowledged: false });
+ expect(errors.acknowledged).toBeTruthy();
+ });
+
+ it("validates the optional reference URL only when provided", () => {
+ expect(validateSupportRequest({ ...VALID, referenceUrl: "" }).referenceUrl).toBeUndefined();
+ expect(
+ validateSupportRequest({ ...VALID, referenceUrl: "not a url" }).referenceUrl
+ ).toBeTruthy();
+ expect(
+ validateSupportRequest({ ...VALID, referenceUrl: "https://example.test/x" }).referenceUrl
+ ).toBeUndefined();
+ });
+});
+
+describe("supportRequestSubject", () => {
+ it("prefers the organization, falls back to the requester's name", () => {
+ expect(supportRequestSubject(VALID)).toBe(
+ "Community Support Request: Saba Youth Football"
+ );
+ expect(supportRequestSubject({ ...VALID, organization: "" })).toBe(
+ "Community Support Request: Alice Johnson"
+ );
+ });
+
+ it("never carries newlines into the header line", () => {
+ expect(
+ supportRequestSubject({ ...VALID, organization: "Team\nBCC: spam@x.test" })
+ ).not.toContain("\n");
+ });
+});
+
+describe("supportRequestEmailBody", () => {
+ it("includes every filled field with labels staff can scan", () => {
+ const body = supportRequestEmailBody(VALID);
+ for (const fragment of [
+ "Name: Alice Johnson",
+ "Email: alice@example.test",
+ "Organization / group / project: Saba Youth Football",
+ "Phone / WhatsApp: +599 416 0000",
+ "Category: Youth",
+ "Type of support requested: Financial support or sponsorship, Goods or supplies",
+ "Estimated amount or value: USD 500",
+ "Sponsorship of team uniforms",
+ "under-14 team needs new uniforms",
+ "Who benefits: About 30 kids aged 8-14",
+ "When / timeline: October 2026",
+ "Uniforms and league fees",
+ "paying a supplier directly or purchasing goods: Yes",
+ "Link to project/event info: https://example.test/team",
+ "information above is accurate",
+ ]) {
+ expect(body, `missing ${fragment}`).toContain(fragment);
+ }
+ });
+
+ it("omits empty optional fields rather than printing blank labels", () => {
+ const body = supportRequestEmailBody({
+ ...VALID,
+ organization: "",
+ phone: "",
+ amount: "",
+ referenceUrl: "",
+ });
+ expect(body).not.toContain("Organization / group / project:");
+ expect(body).not.toContain("Phone / WhatsApp:");
+ expect(body).not.toContain("Estimated amount or value");
+ expect(body).not.toContain("Link to project/event info");
+ });
+
+ it("records the direct-payment answer honestly in both directions", () => {
+ expect(supportRequestEmailBody({ ...VALID, vendorPayment: true })).toContain(
+ "paying a supplier directly or purchasing goods: Yes"
+ );
+ expect(supportRequestEmailBody({ ...VALID, vendorPayment: false })).toContain(
+ "paying a supplier directly or purchasing goods: No"
+ );
+ });
+});
+
+describe("handoff URLs", () => {
+ it("builds a mailto to the canonical inbox with encoded subject and body", () => {
+ const href = buildSupportRequestMailto(VALID);
+ const url = new URL(href);
+ expect(`${url.protocol}${url.pathname}`).toBe(`mailto:${CONTACT.email}`);
+ expect(url.searchParams.get("subject")).toBe(
+ "Community Support Request: Saba Youth Football"
+ );
+ const body = url.searchParams.get("body") ?? "";
+ expect(body).toContain("Name: Alice Johnson");
+ // mailto newlines are normalized to CRLF — the broadest mailto form.
+ expect(body).toContain("\r\n");
+ });
+
+ it("builds a wa.me URL carrying the same structured request", () => {
+ const href = buildSupportRequestWhatsAppUrl(VALID);
+ expect(href.startsWith(`https://wa.me/${CONTACT.whatsappNumber}?text=`)).toBe(true);
+ const text = decodeURIComponent(href.split("?text=")[1]);
+ expect(text).toContain("Alice Johnson");
+ expect(text).toContain("Sponsorship of team uniforms");
+ });
+
+ it("every field has a length cap — the mailto URI cannot grow unbounded", () => {
+ for (const [field, max] of Object.entries(SUPPORT_REQUEST_LIMITS)) {
+ expect(max, field).toBeGreaterThan(0);
+ expect(max, field).toBeLessThanOrEqual(2000);
+ }
+ });
+});