diff --git a/app/(en)/(content)/donate/page.tsx b/app/(en)/(content)/donate/page.tsx new file mode 100644 index 0000000..6abbf1c --- /dev/null +++ b/app/(en)/(content)/donate/page.tsx @@ -0,0 +1,102 @@ +import Link from "next/link"; +import { createMetadata } from "@/lib/metadata"; +import { PageHero } from "@/components/page-hero"; +import { DonationsSection } from "@/components/donations/donations-section"; +import { CommunitySupportSection } from "@/components/donations/community-support-section"; +import { DONATION_RECIPIENTS } from "@/data/donations"; +import { OPERATIONS } from "@/data/operations"; +import { divingAnchors } from "@/lib/anchors"; + +export const metadata = createMetadata({ + title: "Support Saba", + description: + "Ways to support Saba beyond your visit, and how island organizations, projects, and events can request a donation or sponsorship from Sea Saba.", + path: "/donate", +}); + +export default function DonatePage() { + return ( + <> + + +

+ Visitors fall for Saba quickly: the reefs, the trails, the quiet + villages, and the people who keep it all running. Many guests ask how + they can give something back once they're home. And on the island + itself, community groups, teams, and projects regularly need a hand. + This page is for both. +

+

+ If you're a visitor, the most meaningful support goes straight to + the island organizations doing the work. If you're on Saba and + looking for a donation or sponsorship from Sea Saba, scroll down to{" "} + + Request Support from Sea Saba + + . +

+ + {/* Your Visit Already Helps */} +
+

+ Your Visit Already Helps +

+

+ Everything we dive sits inside the Saba National Marine Park, and + every dive carries a conservation contribution of{" "} + ${OPERATIONS.conservationFees.marineParkPerDiveUsd + OPERATIONS.conservationFees.chamberContributionPerDiveUsd}{" "} + per diver: ${OPERATIONS.conservationFees.marineParkPerDiveUsd} to the + Marine Park and ${OPERATIONS.conservationFees.chamberContributionPerDiveUsd}{" "} + to the island's hyperbaric chamber fund. Snorkel trips contribute{" "} + ${OPERATIONS.conservationFees.snorkelParkPerPersonUsd} per person. + These fees are set by the park and the chamber, not Sea Saba, and they + fund the moorings, patrols, and emergency coverage that keep the + reefs healthy. +

+

+ + How the Saba Marine Park works + +

+
+ + {/* Ways to Give Back */} +
+

+ Ways to Give Back +

+

+ When you're ready to do more, a number of island organizations + welcome direct support. Every card below links out to the + organization's own website. Your donation goes to them, not + through us. +

+ + + +

+ Any donation link on this page opens the organization's own + website in a new tab. Sea Saba never collects, processes, or retains + donated funds. Each organization manages its own gifts and receipts. +

+
+ + {/* Request Support from Sea Saba — the other half of the page: local + organizations and project leads asking Sea Saba for help. All policy + content renders from data/community-support.ts (DRAFT pending owner + review). */} + + + ); +} diff --git a/app/(en)/(content)/plan-your-trip/page.tsx b/app/(en)/(content)/plan-your-trip/page.tsx index 6ac9f3e..8f931d7 100644 --- a/app/(en)/(content)/plan-your-trip/page.tsx +++ b/app/(en)/(content)/plan-your-trip/page.tsx @@ -939,6 +939,12 @@ export default function PlanYourTripPage() { +

+ Many visitors leave asking how they can give something back to the island.{" "} + + See ways to support Saba + . +

{/* FAQ */} diff --git a/app/(en)/(content)/privacy/page.tsx b/app/(en)/(content)/privacy/page.tsx index 341fa1b..201482f 100644 --- a/app/(en)/(content)/privacy/page.tsx +++ b/app/(en)/(content)/privacy/page.tsx @@ -22,7 +22,7 @@ export default function PrivacyPage() {

- Last updated: August 2026 + Last updated: September 2026

@@ -38,6 +38,7 @@ export default function PrivacyPage() {
  • Travel dates and booking details
  • Certification level and dive experience
  • Information submitted through reservation forms
  • +
  • Donation, sponsorship, or community-support request details you send us
  • Information submitted through liability waivers or medical questionnaires, when applicable
  • Messages and inquiries
  • Payment and transaction information necessary to process reservations
  • @@ -131,9 +132,9 @@ export default function PrivacyPage() { If you contact Sea Saba through email, WhatsApp, website chat, or website forms, we may retain those communications to provide customer service and assist with future inquiries.

    - Our website contact form does not transmit your message to us directly — it opens a - pre-filled email in your own email application, and the message is sent from your email - account when you choose to send it. + Our website contact and support-request forms do not transmit your message to us + directly. They open a pre-filled email in your own email application, and the message + is sent from your email account when you choose to send it.

    The website chat widget is provided by Respond.io. When you open the chat and submit the diff --git a/app/sitemap.ts b/app/sitemap.ts index a34c262..2d404ce 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -28,6 +28,7 @@ const SITEMAP_ROUTES: { { path: "/about", changeFrequency: "monthly", priority: 0.7 }, { path: "/contact", changeFrequency: "monthly", priority: 0.6 }, { path: "/partners", changeFrequency: "monthly", priority: 0.6 }, + { path: "/donate", changeFrequency: "monthly", priority: 0.6 }, { path: "/terms", changeFrequency: "yearly", priority: 0.3 }, { path: "/privacy", changeFrequency: "yearly", priority: 0.3 }, { path: "/cookie-policy", changeFrequency: "yearly", priority: 0.3 }, diff --git a/components/donations/community-support-section.tsx b/components/donations/community-support-section.tsx new file mode 100644 index 0000000..6f211b3 --- /dev/null +++ b/components/donations/community-support-section.tsx @@ -0,0 +1,109 @@ +import { + SUPPORT_REQUEST_CATEGORIES, + SUPPORT_TYPES, + WHO_CAN_REQUEST, + SUPPORT_STANDARDS, + SUPPORT_REQUEST_STEPS, + SUPPORT_REQUEST_NO_GUARANTEE, +} from "@/data/community-support"; +import { SupportRequestForm } from "./support-request-form"; + +/** + * The request side of /donate (#171): who may ask Sea Saba for support, what + * kinds of help are possible, the ground rules, the process, and the request + * form. All policy content renders from data/community-support.ts — edit the + * wording there, not here. DRAFT copy pending owner review. + */ +export function CommunitySupportSection() { + return ( +

    +

    + Request Support from Sea Saba +

    +

    + Sea Saba has been part of this island since 1985, and giving back is part + of how we operate: a raffle prize for a school fundraiser, air fills for + a beach cleanup, sponsorship for a youth team. If you're working on + something worthwhile on Saba, we'd like to hear about it. +

    +

    + We're a small local business, so we keep a few clear ground rules, + not to make asking harder, but so we can keep saying yes to good + projects and know our support lands where it's meant to. +

    + + {/* Who can ask */} +

    Who can ask

    +
      + {WHO_CAN_REQUEST.map((item) => ( +
    • {item}
    • + ))} +
    +

    + Requests we see most often fall into a few areas ( + {SUPPORT_REQUEST_CATEGORIES.map((c) => c.label).join(", ").replace(/, ([^,]*)$/, " or $1")} + ), but anything that benefits the island is worth asking about. +

    + + {/* What support can look like */} +

    + What support can look like +

    +

    + It doesn't have to be money. Depending on the request, Sea Saba can help with: +

    +
      + {SUPPORT_TYPES.map((type) => ( +
    • +

      {type.label}

      +

      + {type.description} +

      +
    • + ))} +
    + + {/* Ground rules */} +

    + A few ground rules +

    +
      + {SUPPORT_STANDARDS.map((standard) => ( +
    • +

      {standard.title}

      +

      + {standard.description} +

      +
    • + ))} +
    + + {/* How it works */} +

    How it works

    +
      + {SUPPORT_REQUEST_STEPS.map((step) => ( +
    1. {step}
    2. + ))} +
    +

    + {SUPPORT_REQUEST_NO_GUARANTEE} +

    + + {/* The form */} +

    Send a request

    +

    + This gives us what we need to evaluate your request fairly. It's + not a grant application. Short, honest answers are perfect. +

    +
    + +
    +
    + ); +} diff --git a/components/donations/donations-section.tsx b/components/donations/donations-section.tsx new file mode 100644 index 0000000..72e4039 --- /dev/null +++ b/components/donations/donations-section.tsx @@ -0,0 +1,123 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { ExternalLink } from "lucide-react"; +import { TrackedOutboundLink } from "@/components/tracked-outbound-link"; +import { cn } from "@/lib/utils"; +import type { DonationRecipient } from "@/data/donations"; + +const CATEGORY_LABELS: Record, string> = { + conservation: "Conservation", + community: "Community", + animals: "Animals", + youth: "Youth", + culture: "Culture", + "marine-conservation": "Marine conservation & coral restoration", + "science-education": "Science, education, youth & culture", +}; + +interface DonationsSectionProps { + recipients: DonationRecipient[]; +} + +/** + * Recipient grid for /donate (#171). While DONATION_RECIPIENTS is empty the + * section renders a graceful in-progress note — no fabricated organizations + * may appear publicly. Cards always send visitors to the recipient's own + * site; Sea Saba never processes donations. + */ +export function DonationsSection({ recipients }: DonationsSectionProps) { + if (recipients.length === 0) { + return ( +
    +

    + We're assembling a short list of Saba organizations we can + wholeheartedly recommend. Until it's ready, the most reliable + route is also the simplest:{" "} + + ask us + + . We live here, and we're happy to point you at a cause that fits. +

    +
    + ); + } + + // Three columns only once the registry outgrows two entries — with one or + // two approved recipients a wider card fills the row and keeps the longer + // focus labels on one line instead of leaving an empty third column. + return ( +
    2 && "lg:grid-cols-3" + )} + > + {recipients.map((recipient) => ( + + ))} +
    + ); +} + +function DonationCard({ recipient }: { recipient: DonationRecipient }) { + return ( +
    + {recipient.image && ( + {recipient.imageAlt} + )} +
    +

    {recipient.name}

    + {recipient.category && ( + + {CATEGORY_LABELS[recipient.category]} + + )} +
    +

    + {recipient.description} +

    + {recipient.funds && ( +

    + Helps fund:{" "} + {recipient.funds} +

    + )} + +
    + {recipient.donationUrl && ( + + Donate directly + + )} + + {recipient.donationUrl ? "Visit website" : "Visit their website"} + +
    +
    + ); +} diff --git a/components/donations/support-request-form.tsx b/components/donations/support-request-form.tsx new file mode 100644 index 0000000..f4f01a1 --- /dev/null +++ b/components/donations/support-request-form.tsx @@ -0,0 +1,573 @@ +"use client"; + +import { useState, useCallback, useMemo, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Mail, MailCheck, MessageCircle } from "lucide-react"; +import { trackEvent, trackLinkClick } from "@/lib/analytics"; +import { CONTACT } from "@/lib/constants"; +import { + buildSupportRequestMailto, + buildSupportRequestWhatsAppUrl, + validateSupportRequest, + SUPPORT_REQUEST_LIMITS, + type SupportRequestDraft, + type SupportRequestErrors, +} from "@/lib/support-request"; +import { SUPPORT_REQUEST_CATEGORIES, SUPPORT_TYPES } from "@/data/community-support"; + +const EMPTY_DRAFT: SupportRequestDraft = { + name: "", + organization: "", + email: "", + phone: "", + category: "", + supportTypes: [], + amount: "", + request: "", + description: "", + beneficiaries: "", + timing: "", + useOfSupport: "", + vendorPayment: false, + referenceUrl: "", + acknowledged: false, +}; + +const inputClass = + "w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"; +const labelClass = "text-sm font-medium text-foreground"; + +function FieldError({ id, message }: { id: string; message?: string }) { + if (!message) return null; + return ( +

    + {message} +

    + ); +} + +/** + * "Request Support from Sea Saba" form (#171). Same submission mechanism as + * the contact form: the visitor's own email app (or WhatsApp) sends the + * request, so Respond.io files it under the visitor's real address — no + * shared-sender collapse, no server-side submission, no third-party form + * provider. Analytics receive only generic started/submitted events; field + * contents never leave the page except in the visitor's own handoff. + */ +export function SupportRequestForm() { + const [draft, setDraft] = useState(EMPTY_DRAFT); + const [errors, setErrors] = useState({}); + const [touched, setTouched] = useState>({}); + // The mailto: href built for the most recent handoff — rendered as a + // "reopen" link in the notice so a missed protocol handoff is retryable. + const [handoffHref, setHandoffHref] = useState(""); + const handoffRef = useRef(null); + // Per-method duplicate-click guards — the window is short so deliberate + // resubmission (and switching between email and WhatsApp) still works. + const lastEmailAt = useRef(0); + const lastWhatsAppAt = useRef(0); + const startedRef = useRef(false); + + const wantsFinancial = useMemo( + () => draft.supportTypes.includes("financial"), + [draft.supportTypes] + ); + + // Generic funnel signal only — never field contents. Fires once, when the + // requester first focuses a field. + const markStarted = useCallback(() => { + if (startedRef.current) return; + startedRef.current = true; + trackEvent("donation_request_started", { + button_location: "donate_request_form", + }); + }, []); + + // `errors` always describes the current draft; a field's message renders + // only once it's been touched (or after a submit attempt touches all). + const setField = ( + key: K, + value: SupportRequestDraft[K] + ) => { + const next = { ...draft, [key]: value }; + setDraft(next); + setErrors(validateSupportRequest(next)); + }; + + const handleBlur = useCallback( + (field: string) => { + setTouched((prev) => ({ ...prev, [field]: true })); + setErrors(validateSupportRequest(draft)); + }, + [draft] + ); + + const toggleSupportType = (value: string) => { + const next = { + ...draft, + supportTypes: draft.supportTypes.includes(value) + ? draft.supportTypes.filter((t) => t !== value) + : [...draft.supportTypes, value], + }; + setDraft(next); + setErrors(validateSupportRequest(next)); + setTouched((prev) => ({ ...prev, supportTypes: true })); + }; + + const touchAll = useCallback(() => { + setTouched({ + name: true, + email: true, + category: true, + supportTypes: true, + amount: true, + request: true, + description: true, + beneficiaries: true, + timing: true, + useOfSupport: true, + referenceUrl: true, + acknowledged: true, + }); + }, []); + + const validateAll = useCallback(() => { + const validationErrors = validateSupportRequest(draft); + setErrors(validationErrors); + touchAll(); + return Object.keys(validationErrors).length === 0; + }, [draft, touchAll]); + + // Client-email handoff: build the structured request and open it in the + // visitor's own email app via mailto:. The visitor reviews and sends it + // themselves, so their real address reaches info@seasaba.com and + // Respond.io attaches the request to the correct contact — a shared + // server-side sender collapsed every visitor into one contact (#104). + const handleEmail = useCallback(() => { + if (!validateAll()) return; + if (Date.now() - lastEmailAt.current < 1000) return; + lastEmailAt.current = Date.now(); + + const href = buildSupportRequestMailto(draft); + const eventParams = { method: "email", button_location: "donate_request_form" }; + // Handoff events only — no request details, names, or amounts. + trackEvent("donation_request_submitted", eventParams); + trackLinkClick("email_click", href, "Continue to Email", eventParams); + setHandoffHref(href); + window.open(href, "_self"); + requestAnimationFrame(() => handoffRef.current?.focus()); + }, [validateAll, draft]); + + const handleWhatsApp = useCallback(() => { + if (!validateAll()) return; + if (Date.now() - lastWhatsAppAt.current < 1000) return; + lastWhatsAppAt.current = Date.now(); + + const href = buildSupportRequestWhatsAppUrl(draft); + const eventParams = { method: "whatsapp", button_location: "donate_request_form" }; + trackEvent("donation_request_submitted", eventParams); + trackLinkClick("whatsapp_click", href, "WhatsApp Sea Saba", eventParams); + window.open(href, "_blank", "noopener,noreferrer"); + }, [validateAll, draft]); + + return ( +
    { + e.preventDefault(); + handleEmail(); + }} + onFocusCapture={markStarted} + noValidate + > +
    +
    + + setField("name", e.target.value)} + onBlur={() => handleBlur("name")} + aria-invalid={touched.name && !!errors.name} + aria-describedby={touched.name && errors.name ? "sr-name-error" : undefined} + className={inputClass} + placeholder="Your full name" + /> + +
    + +
    + + setField("organization", e.target.value)} + className={inputClass} + placeholder="e.g. Saba youth football club" + /> +
    +
    + +
    +
    + + setField("email", e.target.value)} + onBlur={() => handleBlur("email")} + aria-invalid={touched.email && !!errors.email} + aria-describedby={touched.email && errors.email ? "sr-email-error" : undefined} + className={inputClass} + placeholder="you@example.com" + /> + +
    + +
    + + setField("phone", e.target.value)} + className={inputClass} + placeholder="+599 416 0000" + /> +
    +
    + +
    + + + +
    + +
    + + Type of support needed *{" "} + (choose all that apply) + +
    + {SUPPORT_TYPES.map((type) => ( + + ))} +
    + +
    + +
    + + setField("amount", e.target.value)} + onBlur={() => handleBlur("amount")} + aria-invalid={touched.amount && !!errors.amount} + aria-describedby={ + touched.amount && errors.amount ? "sr-amount-error sr-amount-hint" : "sr-amount-hint" + } + className={inputClass} + placeholder="e.g. USD 250, or two sets of snorkel gear" + /> +

    + Required when asking for financial support or sponsorship. A rough figure is fine. +

    + +
    + +
    + +