CampgroundTickets/docs/fluentforms-donor-discount.md
Hank 0b4ad1c99f Add ticket-voucher entitlement lookup + document both public APIs
GET /api/public/ticket-vouchers?key=&email= returns 0/1/2 free tickets based on
donations on/after VOUCHER_SINCE (default 2025-09-04): >= $400 -> 1, >= $1000 -> 2.
Same secret/CORS/rate-limit as donor-eligibility; returns only the count. Cutoff
and thresholds are env-configurable. Documented both lookup APIs (discount +
vouchers) in docs/fluentforms-donor-discount.md with form snippets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 06:27:32 +00:00

9.1 KiB

FluentForms → donor lookup APIs

FluentForms has no native way to query an external database from a field. These wire it up with a small Custom JS block that calls a secret-gated endpoint on the ticketing backend. Two endpoints are available (same key, CORS, and rate limit):

Endpoint Purpose
GET /api/public/donor-eligibility Is this email a donor/member? → unlock a discount
GET /api/public/ticket-vouchers How many free tickets has this donor earned? → 0 / 1 / 2

Both require ?key=<PUBLIC_LOOKUP_SECRET>, are rate-limited (30/min/IP), and CORS-restricted to PUBLIC_LOOKUP_ORIGIN (default https://tickets.beartariacampgrounds.com). Neither returns names or dollar amounts. The secret is visible in page source, so treat it as deterrence, not security; rotate it by changing PUBLIC_LOOKUP_SECRET and redeploying.


Donor discount lookup

Unlocks a discount when the entered email belongs to a donor/member.

Endpoint

GET https://scan.beartariacampgrounds.com/api/public/donor-eligibility?key=<SECRET>&email=<email>
  • key = the value of PUBLIC_LOOKUP_SECRET (set in the backend .env).
  • Returns minimal JSON — never names or dollar amounts:
    • {"eligible": true, "tier": "member"}
    • {"eligible": true, "tier": "donor"}
    • {"eligible": false, "tier": null}
  • Rate-limited (30/min/IP) and CORS-restricted to PUBLIC_LOOKUP_ORIGIN (default https://tickets.beartariacampgrounds.com).

The secret is visible in page source, so treat this as deterrence, not security. It only gates a discount and reveals a yes/no + tier, so the blast radius is small. Rotate the secret by changing PUBLIC_LOOKUP_SECRET and redeploying.

Form setup (conditional pricing)

The idea: a hidden field donor_tier holds regular / donor / member. The JS sets it from the email lookup, and your payment options are shown/hidden by FluentForms conditional logic based on its value.

  1. Hidden field. Add a Hidden Field, name it exactly donor_tier, default value regular.
  2. Email field. Note its name (default email).
  3. Payment options. Set up two payment items (or two options of a multiple-choice payment field) — a regular price and a discounted price — and give each conditional logic:
    • Regular price: show when donor_tier is regular
    • Donor price: show when donor_tier is donor OR donor_tier is member (add both rules with "match any").
  4. Custom HTML. Add a Custom HTML element and paste the snippet below, setting KEY to your PUBLIC_LOOKUP_SECRET (and EMAIL_SELECTOR if your email field isn't named email).

FluentForms is Vue-driven, so a plain input.value = … won't update its model and conditional logic won't fire. The snippet uses the native value setter + dispatches input/change, which is the reliable way to make FF notice a programmatic change. Test on your form; if conditional logic still doesn't react, tell me your FF version and I'll adapt.

Snippet

<div id="donor-status" style="margin:6px 0;font-size:14px;font-weight:600;"></div>
<script>
(function () {
  var API = "https://scan.beartariacampgrounds.com/api/public/donor-eligibility";
  var KEY = "REPLACE_WITH_PUBLIC_LOOKUP_SECRET";
  var EMAIL_SELECTOR = 'input[name="email"]';        // adjust if needed
  var TIER_SELECTOR  = 'input[name="donor_tier"]';   // the hidden field
  var statusEl = document.getElementById("donor-status");
  var lastChecked = "";

  // Set a framework-bound input's value so Vue/React actually notice it.
  function setNativeValue(el, value) {
    var proto = Object.getPrototypeOf(el);
    var protoSetter = Object.getOwnPropertyDescriptor(proto, "value");
    var ownSetter = Object.getOwnPropertyDescriptor(el, "value");
    if (ownSetter && protoSetter && ownSetter.set !== protoSetter.set) {
      protoSetter.set.call(el, value);
    } else if (protoSetter) {
      protoSetter.set.call(el, value);
    } else {
      el.value = value;
    }
    el.dispatchEvent(new Event("input", { bubbles: true }));
    el.dispatchEvent(new Event("change", { bubbles: true }));
  }

  function setTier(tier) {
    var el = document.querySelector(TIER_SELECTOR);
    if (el) setNativeValue(el, tier);   // "regular" | "donor" | "member"
  }

  function check(email) {
    if (!email || email === lastChecked) return;
    lastChecked = email;
    statusEl.textContent = "Checking donor status…";
    statusEl.style.color = "#888";
    fetch(API + "?key=" + encodeURIComponent(KEY) + "&email=" + encodeURIComponent(email))
      .then(function (r) { return r.json(); })
      .then(function (d) {
        if (d && d.eligible) {
          setTier(d.tier);                                // "member" or "donor"
          statusEl.textContent = (d.tier === "member" ? "🐻 Member" : "⭐ Donor") + " pricing unlocked!";
          statusEl.style.color = "#1b7f3b";
        } else {
          setTier("regular");
          statusEl.textContent = "";
        }
      })
      .catch(function () { setTier("regular"); statusEl.textContent = ""; });
  }

  function bind() {
    var el = document.querySelector(EMAIL_SELECTOR);
    if (!el) { return setTimeout(bind, 500); }   // form may render late
    setTier("regular");                          // start at regular price
    el.addEventListener("blur", function () { check(el.value.trim().toLowerCase()); });
  }
  bind();
})();
</script>

Test

curl "https://scan.beartariacampgrounds.com/api/public/donor-eligibility?key=<SECRET>&email=<a-real-donor-email>"
# donor/member -> {"eligible":true,"tier":"member"}
# anyone else  -> {"eligible":false,"tier":null}

If member and donor get the same discounted price, simplify: set the donor price to show when donor_tier is not regular, and you can ignore the member/donor distinction.


Ticket-voucher entitlement

Returns how many free tickets a donor has earned from their giving, based on donations on/after a cutoff date (default 2025-09-04 — "9/4 last year").

GET https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<email>
-> {"vouchers": 0}   |   {"vouchers": 1}   |   {"vouchers": 2}

Rules (donations summed on/after the cutoff):

Total since cutoff Vouchers
≥ $1000 2
≥ $400 1
otherwise 0

Config (backend .env):

Var Default Meaning
VOUCHER_SINCE 2025-09-04 Only donations on/after this date count. Bump each year.
VOUCHER_TIER1_MIN 400 Dollar total for 1 voucher
VOUCHER_TIER2_MIN 1000 Dollar total for 2 vouchers

The count is computed from the dated transaction tables (online + offline) — the master-list rollups have no dates. Only Paid transactions count.

Form snippet

Displays the voucher count and (optionally) sets a hidden field / caps a quantity. Same pattern as the discount lookup — paste into a Custom HTML element and set KEY.

<div id="voucher-status" style="margin:6px 0;font-size:14px;font-weight:600;"></div>
<script>
(function () {
  var API = "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers";
  var KEY = "REPLACE_WITH_PUBLIC_LOOKUP_SECRET";
  var EMAIL_SELECTOR = 'input[name="email"]';
  var statusEl = document.getElementById("voucher-status");
  var last = "";

  function show(n) {
    if (n > 0) {
      statusEl.textContent = "🎟️ You've earned " + n + " free ticket" + (n > 1 ? "s" : "") + "!";
      statusEl.style.color = "#1b7f3b";
    } else {
      statusEl.textContent = "";
    }
    // Optional: write n into a hidden field named "free_tickets" for conditional
    // logic / to cap a quantity. (Uses the native setter so FF's Vue model sees it.)
    var el = document.querySelector('input[name="free_tickets"]');
    if (el) {
      var proto = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), "value");
      (proto && proto.set ? proto.set : function (v) { el.value = v; }).call(el, String(n));
      el.dispatchEvent(new Event("input", { bubbles: true }));
      el.dispatchEvent(new Event("change", { bubbles: true }));
    }
  }

  function check(email) {
    if (!email || email === last) return;
    last = email;
    fetch(API + "?key=" + encodeURIComponent(KEY) + "&email=" + encodeURIComponent(email))
      .then(function (r) { return r.json(); })
      .then(function (d) { show(d && d.vouchers ? d.vouchers : 0); })
      .catch(function () { show(0); });
  }

  function bind() {
    var el = document.querySelector(EMAIL_SELECTOR);
    if (!el) { return setTimeout(bind, 500); }
    el.addEventListener("blur", function () { check(el.value.trim().toLowerCase()); });
  }
  bind();
})();
</script>

Test

curl "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<a-real-donor-email>"
# -> {"vouchers":2}   (>= $1000 since the cutoff)

Heads-up on the cutoff: with VOUCHER_SINCE=2025-09-04, everyone currently returns 0 because the donation data in NocoDB ends 2025-05-22 — there are no transactions after the cutoff yet. Adjust VOUCHER_SINCE (or wait for new donations to sync) so the window matches real giving.