For developers

Donation SDK

Embed a Chinuch donor-board Donate button on any external website. Sell sponsorship slots (a week, a Parsha) and physical/conceptual items (a chair, a Chumash) — or accept generic donations — using a single drop-in script.

Overview

The donation system has three pieces:

  1. Donor Board admin at /dashboard/school/donor-board. This is where you create boards (widgets), time-period slots, donation categories, and donation items.
  2. The embed script at https://chinuchapp.com/donate-button.js. Drop it on any website. It auto-binds a click handler to every element with a data-donate attribute and opens the donate form in a centered modal popup.
  3. The public REST API at https://chinuchapp.com/api/donor-board/<publicKey>/.... Use it directly if you need a custom UI (a multi-step flow, native mobile app, etc.) instead of the supplied modal.

Two equally good integration paths

Embed script = zero JS, fastest to ship, full themeable form rendered in an iframe. REST API = full design control, you build the UI, you call the endpoints. They use the same backend so you can mix and match.

Concepts & Data Model

ConceptWhat it isURL identifier
SchoolYour organization. Has a public_key that's safe to put on your website.publicKey
Widget / BoardA standalone donor-board campaign (e.g. "Building Fund", "Annual Campaign"). Has its own theme, payment methods, slots, categories, and items.slug
Sponsorship SlotA time period that one donor sponsors (a week, day, or month). Single-sponsor.slot id
Donation CategoryA grouping of items (e.g. "Furniture", "Books").category id
Donation ItemA specific thing to sponsor (e.g. "Stackable Chair") with a suggested amount and an optional quantity_goal. Multi-sponsor: 50 chairs needed = 50 different sponsors.item id
PledgeThe donor's record (name, amount, dedication, payment status). One pledge per slot, or 1+ per item.pledge id

Where to find your IDs

  • publicKey — your school's row in the platform already has one. The donor-board admin shows the embed code with your publicKey pre-filled.
  • widget slug — set when you create a board ("main", "building-fund", etc.). Visible in the URL and in the widget selector.
  • item id / category id — open the Items tab inside any board and click Embed on a category or item — the popup shows the exact snippet to copy.

Quick Start

A minimal page with a working donate button — copy this into a new HTML file and replace YOUR_PUBLIC_KEY:

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Donate</title>
</head>
<body>
  <h1>Support our school</h1>

  <!-- 1. Any element with data-donate becomes a donate trigger -->
  <button data-donate="YOUR_PUBLIC_KEY">Donate</button>

  <!-- 2. Load the script once per page (async is fine) -->
  <script src="https://chinuchapp.com/donate-button.js" async></script>
</body>
</html>

That's it. Clicking the button opens a centered modal popup with your school's branded donate form. The form supports Stripe, PayPal, Zelle, Cash App and pledge-only mode based on your widget settings.

Embed Script Reference

https://chinuchapp.com/donate-button.js is a single-file script (no dependencies, IIFE-wrapped, ~7 KB) that:

  • Auto-binds a click listener to every element with data-donate.
  • Opens a fixed-position overlay (z-index 2147483647) containing an iframe pointed at /embed/<publicKey>/donate.
  • Listens for postMessage events from the iframe (resize, success, close).
  • Closes on outside-click, on the close (×) button, on Esc, or on a donate-button-close message from the iframe.
  • Re-runs the auto-binder whenever the DOM mutates (so SPA-rendered buttons just work).
  • Exposes window.ChinuchDonate for programmatic use.

Loading the script

<!-- Standard async tag (recommended) -->
<script src="https://chinuchapp.com/donate-button.js" async></script>

<!-- Defer is fine too -->
<script src="https://chinuchapp.com/donate-button.js" defer></script>

<!-- Override the host (useful for local dev / staging) -->
<script>window.CHINUCH_DONATE_HOST = "https://staging.chinuchapp.com";</script>
<script src="https://staging.chinuchapp.com/donate-button.js" async></script>

The script is idempotent — loading it multiple times is harmless (subsequent loads bail early via window.ChinuchDonate.__loaded).

data-* Attributes

Put these on any element — <button>, <a>, <div>, <img> — and the script will turn it into a donate trigger.

AttributeRequiredPurpose
data-donateYesYour school's public_key. Marks the element as a donate trigger.
data-itemNoDonate to a specific donation item. Form pre-fills with the item's name, image, suggested amount, and progress bar.
data-categoryNoDonate to an entire category. Donor picks any amount toward that category.
data-widgetNoWidget slug (e.g. building-fund). Useful for generic donations to a particular board. Defaults to main.
data-amountNoPre-fills the donation amount in dollars (e.g. data-amount="180"). Donor can still change it.

Examples — every shape

<!-- 1. Generic donate button -->
<button data-donate="YOUR_PUBLIC_KEY">Donate</button>

<!-- 2. Donate to a specific item (multi-sponsor with progress) -->
<button data-donate="YOUR_PUBLIC_KEY" data-item="ITEM_UUID">
  Sponsor a chair
</button>

<!-- 3. Donate to a category (donor picks amount) -->
<button data-donate="YOUR_PUBLIC_KEY" data-category="CATEGORY_UUID">
  Donate to the Building Fund
</button>

<!-- 4. Donate to a specific widget / board -->
<button data-donate="YOUR_PUBLIC_KEY" data-widget="annual-campaign">
  Donate to the Annual Campaign
</button>

<!-- 5. Pre-fill the amount -->
<button data-donate="YOUR_PUBLIC_KEY" data-amount="180">
  Donate $180
</button>

<!-- 6. Wrap an image so the whole image is clickable -->
<a href="#" data-donate="YOUR_PUBLIC_KEY" data-item="ITEM_UUID">
  <img src="/images/chair.jpg" alt="Sponsor a chair" />
</a>

<!-- 7. Wrap a styled card -->
<div data-donate="YOUR_PUBLIC_KEY" data-item="ITEM_UUID" class="my-card">
  <img src="chair.jpg" alt="">
  <h3>Stackable Chair</h3>
  <p>$100 each — 12 of 50 sponsored</p>
</div>

<!-- 8. Combine — item + pre-filled amount -->
<a href="#" data-donate="YOUR_PUBLIC_KEY"
   data-item="ITEM_UUID" data-amount="100">
  <img src="chair.jpg" alt="">
</a>

Click target hints

The script automatically applies cursor: pointer to non-button, non-link triggers so they feel clickable. Add your own hover styles for full polish.

Programmatic API

Once the script is loaded, you can open and close the modal from your own JavaScript via window.ChinuchDonate.

window.ChinuchDonate.open(opts)

Opens the donate modal. opts object:

FieldTypeNote
publicKeystringRequired.
itemIdstring?Donation item UUID.
categoryIdstring?Category UUID.
widgetSlugstring?Widget slug. Default main.
amountnumber?Pre-filled dollar amount.

window.ChinuchDonate.close()

Closes the modal and resets the iframe to about:blank.

window.ChinuchDonate.attach()

Re-scans the document for data-donate elements and binds the click handler. The script does this automatically via a MutationObserver, but you can call this manually after a heavy DOM update.

// Open programmatically when a custom button is clicked
document.getElementById('my-fancy-button').addEventListener('click', () => {
  window.ChinuchDonate.open({
    publicKey: 'YOUR_PUBLIC_KEY',
    itemId: 'ITEM_UUID',
    amount: 100,
  })
})

// Close after some delay
setTimeout(() => window.ChinuchDonate.close(), 5000)

// Wait for the script to load (if you're using ES modules / async loaders)
function whenReady(cb) {
  if (window.ChinuchDonate?.__loaded) return cb()
  const t = setInterval(() => {
    if (window.ChinuchDonate?.__loaded) {
      clearInterval(t)
      cb()
    }
  }, 50)
}

whenReady(() => {
  // safe to call .open() now
})

Events

The script dispatches a CustomEvent on document when a donation completes successfully — useful for analytics, custom thank-you screens, or triggering a page refresh.

Event nameWhenevent.detail
chinuchDonateSuccessAfter a pledge or payment is recorded.{ type, publicKey, amount, itemId, categoryId }
chinuchDonateModelLoadedFires when a donate target has a 3D model attached and the <model-viewer> inside the iframe finishes loading the GLB. Skipped for items with no 3D.{ type, publicKey, itemId, glbUrl }
document.addEventListener('chinuchDonateSuccess', (e) => {
  console.log('Donation complete', e.detail)
  // e.detail = {
  //   type: 'donate-button-success',
  //   publicKey: 'abc123',
  //   amount: 180,
  //   itemId: 'a1b2-...' | null,
  //   categoryId: 'c3d4-...' | null,
  // }

  // Example: fire a Google Analytics event
  if (window.gtag) {
    gtag('event', 'donate', {
      currency: 'USD',
      value: e.detail.amount,
    })
  }
})

When does the modal close?

After success, the donate page shows its own thank-you screen for ~2.5 seconds, then posts donate-button-close back, which dismisses the overlay automatically. Don't call ChinuchDonate.close() inside the success handler — the user wouldn't see the confirmation.

3D Models

Donation items can carry an optional rotating 3D model (.glb) on top of the regular image_url. When present, the donate iframe shows an auto-rotating turntable instead of the static photo. Donors can't interact with it (it's a passive showcase), but it does far better than a single photo for things like buildings, furniture, and ritual objects.

How a 3D model gets attached

The school admin generates the model from inside the donor-board admin page — /dashboard/school/donor-board?tab=items → open any item → 3D Model section. The pipeline:

  1. Render image — Gemini renders a clean front-three-quarter catalog hero-shot from the existing photo or a text prompt + optional reference. The new image becomes the item's image_url.
  2. Generate 3D — Meshy runs image-to-3D on the rendered photo and returns a textured GLB. The file is mirrored into our public bucket so the URL is stable.

The two stages are independent — admins can preview and re-render the photo until they like it before paying for / waiting on a Meshy job (~30-90 s).

What changes for you

Nothing. The existing embed script and donate iframe pick the 3D model up automatically when it's set on the item:

  • data-donate + data-item snippets you already have in production keep working — when the item gains a GLB, donors start seeing it without you redeploying anything.
  • The 3D viewer falls back to the static image_url automatically if the GLB or its textures fail to load (network hiccup, CORS, decoder error). Donors always see something.
  • If you want to react when the GLB finishes loading (e.g. fade in a surrounding card, fire an analytics event), listen for the chinuchDonateModelLoaded event documented above.

Detecting 3D items via the REST API

The /items and /items/[itemId] endpoints return four extra fields when an item has a model:

FieldTypeNote
glb_urlstring | nullPublic URL of the GLB. Only populated once model_status === 'ready'.
model_thumbnail_urlstring | nullProvider-rendered preview PNG, used as the model's poster while the GLB loads.
model_statusstringOne of idle, generating_catalog, generating_model, ready, error.
model_providerstring | nullWhich provider built the GLB. Currently meshy; reserved for future routing.

Render a 3D thumbnail in your own grid

If you build a custom item grid (rather than using the embed modal), drop in Google's <model-viewer> web component when item.glb_url is set. It's a single CDN script and renders identically to the donate iframe.

<!-- Once per page -->
<script type="module"
  src="https://ajax.googleapis.com/ajax/libs/model-viewer/4.2.0/model-viewer.min.js"></script>

<!-- Per item that has a glb_url -->
<model-viewer
  src="${item.glb_url}"
  poster="${item.model_thumbnail_url || item.image_url}"
  alt="${item.name}"
  auto-rotate
  rotation-per-second="22deg"
  interaction-prompt="none"
  disable-zoom
  disable-pan
  disable-tap
  tone-mapping="aces"
  exposure="1.1"
  environment-image="legacy"
  shadow-intensity="0.6"
  style="width: 100%; aspect-ratio: 16/9; background: transparent;">
</model-viewer>

Why these <model-viewer> attributes

tone-mapping="aces" + environment-image="legacy" are the v3-style defaults that render Meshy GLBs the way the provider preview shows them. The v4 default of neutral for both bleaches matte product textures (upstream google/model-viewer#4825).

// Fetch items, render 3D viewers when available, otherwise fall back to <img>
fetch(`https://chinuchapp.com/api/donor-board/${PUBLIC_KEY}/items`)
  .then(r => r.json())
  .then(({ items }) => {
    items.forEach(it => {
      const has3d = it.model_status === 'ready' && it.glb_url
      const media = has3d
        ? `<model-viewer src="${it.glb_url}"
                          poster="${it.model_thumbnail_url || it.image_url || ''}"
                          auto-rotate disable-zoom interaction-prompt="none"
                          tone-mapping="aces" environment-image="legacy"
                          style="width:100%;aspect-ratio:1;"></model-viewer>`
        : `<img src="${it.image_url || ''}" alt="" loading="lazy">`
      // ...append a card with ${media} + name + suggested_amount + data-donate/data-item
    })
  })

Polling for in-flight 3D jobs

If you display the item before the school admin has finished generating its 3D model, you'll see model_status === 'generating_model' with no glb_url yet. Re-fetch the item every few seconds until status is ready or error, or just wait for the next page load — typical Meshy run is 30-90 s.

Styling the Trigger

The trigger element is yours to style however you like — the script doesn't add any classes or styles to it (except a cursor: pointer hint on non-button/anchor triggers).

The modal (the overlay and the iframe) is rendered by the script itself with inline styles, so it isolates from your site's CSS. The donate form rendered inside the iframe uses your widget's theme settings — primary color, fonts, logo, etc.

Suggested CSS for a polished button

/* A nice solid donate button */
.donate-btn {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.75rem 1.5rem;
  background: #1E40AF;
  color: white;
  font-weight: 600;
  border-radius: 9999px;
  border: 0;
  cursor: pointer;
  transition: transform 120ms, box-shadow 120ms;
}
.donate-btn:hover {
  transform: translateY(-1px);
  box-shadow: 0 6px 16px rgba(30,64,175,0.25);
}

/* An image card that becomes a clickable donate trigger */
.donate-card {
  position: relative;
  display: block;
  width: 320px;
  border-radius: 12px;
  overflow: hidden;
  text-decoration: none;
  color: inherit;
  box-shadow: 0 2px 8px rgba(0,0,0,0.08);
  transition: transform 200ms, box-shadow 200ms;
}
.donate-card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.12); }
.donate-card img  { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; }
.donate-card .body { padding: 12px 14px; }
<button class="donate-btn" data-donate="YOUR_PUBLIC_KEY" data-item="ITEM_UUID">
  ❤ Sponsor a chair
</button>

<a href="#"
   class="donate-card"
   data-donate="YOUR_PUBLIC_KEY"
   data-item="ITEM_UUID">
  <img src="/images/chair.jpg" alt="">
  <div class="body">
    <h3 style="margin:0 0 4px">Stackable Chair</h3>
    <p style="margin:0;color:#64748b;font-size:14px">$100 · 12 of 50 sponsored</p>
  </div>
</a>

Public REST API

All endpoints live under https://chinuchapp.com/api/donor-board/<publicKey>. They accept anonymous requests (no auth header). Rate-limited to ~10 writes per minute per IP. Response is JSON.

GET /[publicKey]

The full board data: settings, slots, and any pledges already on them. Used to render the slots grid.

// Fetch the main board
const r = await fetch(
  'https://chinuchapp.com/api/donor-board/YOUR_PUBLIC_KEY?widget=main'
)
const { settings, slots, school } = await r.json()
// settings   — { theme_json, amount_rules_json, payment_methods_json, ... }
// slots      — [{ id, from_date, to_date, metadata_json, pledge?, ... }]
// school     — { id, name, public_key }

GET /[publicKey]/items

List every category and donation item for a widget.

const r = await fetch(
  'https://chinuchapp.com/api/donor-board/YOUR_PUBLIC_KEY/items?widget=main'
)
const { settings, categories, items, school } = await r.json()
// categories — [{ id, name, description, image_url, sort_order, ... }]
// items      — [{
//   id,
//   category_id,
//   name,
//   description,
//   image_url,
//   suggested_amount,
//   quantity_goal,         // null = unlimited
//   quantity_sponsored,    // auto-maintained, count of pledges so far
//   sort_order,
//   // 3D model (optional, see "3D Models" section)
//   glb_url,               // string | null - GLB URL when model is ready
//   model_thumbnail_url,   // string | null - poster while GLB loads
//   model_status,          // 'idle' | 'generating_catalog'
//                          // | 'generating_model' | 'ready' | 'error'
//   model_provider,        // 'meshy' | null
//   ...
// }]

// Optional: filter to one category
//   ?widget=main&category=<categoryId>

GET /[publicKey]/items/[itemId]

Detail for a single item, including its category and the widget settings needed to render a donate form.

const r = await fetch(
  'https://chinuchapp.com/api/donor-board/YOUR_PUBLIC_KEY/items/ITEM_UUID'
)
const { item, settings, school } = await r.json()

POST /[publicKey]/donate

The unified donation endpoint. Accepts any target — a slot, an item, a category, or a generic widget donation. Creates a pledge row and returns its id; payment, if any, is collected after.

Request body

{
  "sponsor_name":   "Mr. Cohen",
  "anonymous":      false,
  "email":          "donor@example.com",
  "phone":          "+1-555-123-4567",
  "amount":         100,
  "quantity":       2,                     // for items with a quantity_goal
  "dedication_type":"birthday",            // optional, see enum below
  "dedication_text":"In honor of...",      // optional
  "item_id":        "ITEM_UUID",           // OR
  "category_id":    "CATEGORY_UUID",       // OR
  "slot_id":        "SLOT_UUID",           // OR
  "widget_slug":    "main"                 // OR fallback
  // At least ONE of {item_id, category_id, slot_id, widget_slug} is required.
}

Response (201)

{
  "success": true,
  "pledge": {
    "id":            "uuid",
    "slot_id":       null,
    "item_id":       "uuid",
    "category_id":   "uuid",
    "widget_id":     "uuid",
    "sponsor_name":  "Mr. Cohen",   // "Anonymous Sponsor" if anonymous=true
    "anonymous":     false,
    "amount":        100,
    "quantity":      2,
    "created_at":    "2026-05-07T20:30:00Z"
  }
}

Common errors

  • 400 — Sponsor name is required /Amount must be greater than zero
  • 400 — Only N unit(s) remaining for this item
  • 400 — This slot has already been sponsored
  • 400 — Amount must be $X (fixed-amount widgets)
  • 404 — School not found /Item not found
  • 429 — Too many requests (rate limit)

POST /[publicKey]/pledge?widget=...

Slot-only convenience endpoint. Identical to /donate but requires slot_id in the body.

POST /[publicKey]/create-payment-intent

After a pledge exists, call this to create a Stripe payment intent against the school's connected account. Returns { clientSecret, stripeAccountId }. Confirm with @stripe/stripe-js and the connected-account flag.

POST /api/donor-board/<publicKey>/create-payment-intent
{
  "pledge_id":   "PLEDGE_UUID",
  "amount":      100,
  "widget_slug": "main"
}

POST /update-pledge-status

Mark a pledge as paid (or any other status) after Stripe confirms. The donate-modal does this for you automatically.

POST /api/donor-board/update-pledge-status
{
  "pledge_id":         "PLEDGE_UUID",
  "status":            "paid",
  "payment_intent_id": "pi_..."
}

Dedication type enum

birthdayanniversaryyahrzeitrefuahzechusin_memorylilui_nishmasother

Recipes

A. A grid of items pulled from the API

Fetch your items, render a card for each, and let the embed script handle the click → donate flow.

<div id="items-grid" class="items-grid"></div>

<style>
  .items-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
    gap: 16px;
    margin: 24px 0;
  }
  .item-card {
    display: block; text-decoration: none; color: inherit;
    background: #fff; border: 1px solid #e5e7eb; border-radius: 12px;
    overflow: hidden; cursor: pointer;
    transition: transform 150ms, box-shadow 150ms;
  }
  .item-card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.08); }
  .item-card img   { display: block; width: 100%; aspect-ratio: 16/9; object-fit: cover; background: #f1f5f9; }
  .item-body       { padding: 12px 14px; }
  .item-title      { margin: 0 0 4px; font-size: 16px; font-weight: 600; }
  .item-amount     { margin: 0; color: #1E40AF; font-weight: 600; }
  .item-progress   { margin-top: 8px; height: 6px; background: #f1f5f9; border-radius: 3px; overflow: hidden; }
  .item-progress > i { display: block; height: 100%; background: #1E40AF; }
</style>

<script>
  const PUBLIC_KEY = 'YOUR_PUBLIC_KEY'

  fetch(`https://chinuchapp.com/api/donor-board/${PUBLIC_KEY}/items`)
    .then(r => r.json())
    .then(({ items }) => {
      const grid = document.getElementById('items-grid')
      grid.innerHTML = items.map(it => {
        const pct = it.quantity_goal
          ? Math.min(100, Math.round(it.quantity_sponsored / it.quantity_goal * 100))
          : 0
        return `
          <a href="#" class="item-card"
             data-donate="${PUBLIC_KEY}" data-item="${it.id}">
            <img src="${it.image_url || ''}" alt="">
            <div class="item-body">
              <h3 class="item-title">${it.name}</h3>
              <p class="item-amount">$${it.suggested_amount}</p>
              ${it.quantity_goal ? `
                <div class="item-progress"><i style="width:${pct}%"></i></div>
                <small>${it.quantity_sponsored} of ${it.quantity_goal} sponsored</small>
              ` : ''}
            </div>
          </a>
        `
      }).join('')

      // The donate-button.js MutationObserver picks these up automatically,
      // but we can call attach() to be explicit:
      window.ChinuchDonate?.attach()
    })
</script>

<script src="https://chinuchapp.com/donate-button.js" async></script>

B. Custom thank-you redirect after success

document.addEventListener('chinuchDonateSuccess', (e) => {
  // Wait a moment so the user sees the in-modal confirmation, then redirect.
  setTimeout(() => {
    const params = new URLSearchParams({
      amount: e.detail.amount,
      itemId: e.detail.itemId || '',
    })
    window.location.href = '/thank-you?' + params.toString()
  }, 2500)
})

C. Pure REST — no embed script, custom UI

Use this if you want full control over the donate UI (e.g. you're building a multi-step flow or a native mobile app).

async function donate({ publicKey, itemId, amount, sponsorName, email }) {
  const res = await fetch(
    `https://chinuchapp.com/api/donor-board/${publicKey}/donate`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        sponsor_name: sponsorName,
        email,
        amount,
        item_id: itemId,
        widget_slug: 'main',
      }),
    },
  )
  if (!res.ok) {
    const { error } = await res.json()
    throw new Error(error || 'Donation failed')
  }
  const { pledge } = await res.json()
  return pledge // { id, sponsor_name, amount, ... }
}

// Usage
donate({
  publicKey:   'YOUR_PUBLIC_KEY',
  itemId:      'ITEM_UUID',
  amount:      100,
  sponsorName: 'Mr. Cohen',
  email:       'donor@example.com',
})
  .then(pledge => alert('Thanks! Pledge id: ' + pledge.id))
  .catch(err => alert('Error: ' + err.message))

D. Pre-fill the amount from URL params

Useful for email campaigns where you link to your site with ?donate=180.

const amount = new URLSearchParams(window.location.search).get('donate')
if (amount) {
  // wait for the script then open
  const t = setInterval(() => {
    if (window.ChinuchDonate?.__loaded) {
      clearInterval(t)
      window.ChinuchDonate.open({
        publicKey: 'YOUR_PUBLIC_KEY',
        amount: Number(amount),
      })
    }
  }, 50)
}

React / Vue / Next.js

The embed script doesn't need any framework wrapper — the data-donate attribute works on whatever you render. These snippets show the cleanest way to load the script in popular frameworks.

Next.js (App Router)

// app/donate/page.tsx
import Script from 'next/script'

export default function DonatePage() {
  return (
    <>
      <h1>Donate</h1>

      <button data-donate="YOUR_PUBLIC_KEY" data-item="ITEM_UUID" className="donate-btn">
        Sponsor a chair
      </button>

      <Script
        src="https://chinuchapp.com/donate-button.js"
        strategy="afterInteractive"
      />
    </>
  )
}

React (any setup) — typed wrapper

// hooks/useChinuchDonate.ts
import { useEffect } from 'react'

declare global {
  interface Window {
    ChinuchDonate?: {
      __loaded: boolean
      open: (opts: {
        publicKey: string
        itemId?: string
        categoryId?: string
        widgetSlug?: string
        amount?: number
      }) => void
      close: () => void
      attach: () => void
    }
  }
}

export function useChinuchDonate() {
  useEffect(() => {
    if (document.querySelector('script[data-chinuch-donate]')) return
    const s = document.createElement('script')
    s.src = 'https://chinuchapp.com/donate-button.js'
    s.async = true
    s.setAttribute('data-chinuch-donate', '1')
    document.body.appendChild(s)
  }, [])
}

// components/DonateButton.tsx
import { useChinuchDonate } from '../hooks/useChinuchDonate'

export function DonateButton({
  publicKey, itemId, amount, children,
}: {
  publicKey: string
  itemId?: string
  amount?: number
  children: React.ReactNode
}) {
  useChinuchDonate()
  return (
    <button
      type="button"
      data-donate={publicKey}
      data-item={itemId}
      data-amount={amount}
      className="donate-btn"
    >
      {children}
    </button>
  )
}

// usage
<DonateButton publicKey="YOUR_PUBLIC_KEY" itemId="ITEM_UUID" amount={100}>
  Sponsor a chair
</DonateButton>

Vue 3

<script setup>
import { onMounted } from 'vue'

onMounted(() => {
  if (document.querySelector('script[data-chinuch-donate]')) return
  const s = document.createElement('script')
  s.src = 'https://chinuchapp.com/donate-button.js'
  s.async = true
  s.setAttribute('data-chinuch-donate', '1')
  document.body.appendChild(s)
})

const props = defineProps({
  publicKey: { type: String, required: true },
  itemId:    String,
  amount:    Number,
})
</script>

<template>
  <button
    type="button"
    :data-donate="publicKey"
    :data-item="itemId"
    :data-amount="amount"
    class="donate-btn"
  >
    <slot>Donate</slot>
  </button>
</template>

Full Page Example

A complete, copy-paste-runnable HTML file an AI agent can use as a starting point. Replace the three YOUR_* placeholders.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Sponsor our school</title>
  <style>
    :root {
      --brand: #1E40AF;
      --brand-dark: #1e3a8a;
      --text: #1e293b;
      --muted: #64748b;
      --border: #e5e7eb;
      --card: #ffffff;
    }
    * { box-sizing: border-box; }
    body {
      margin: 0;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
      color: var(--text);
      background: linear-gradient(180deg, #f8fafc 0%, #fff 200px);
    }
    .container { max-width: 980px; margin: 0 auto; padding: 32px 20px; }
    header { text-align: center; padding: 48px 20px 24px; }
    header h1 { font-size: 32px; margin: 0 0 8px; color: var(--brand-dark); }
    header p  { font-size: 17px; color: var(--muted); margin: 0 0 24px; }

    .donate-btn {
      display: inline-flex; align-items: center; gap: .5rem;
      padding: .85rem 1.75rem;
      background: var(--brand); color: #fff; font-weight: 600;
      border: 0; border-radius: 9999px; cursor: pointer;
      box-shadow: 0 6px 20px rgba(30,64,175,.25);
      transition: transform .12s, box-shadow .12s;
    }
    .donate-btn:hover { transform: translateY(-2px); box-shadow: 0 10px 30px rgba(30,64,175,.35); }
    .donate-btn--ghost { background: transparent; color: var(--brand); box-shadow: none; }

    .grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
      gap: 18px;
      margin-top: 32px;
    }
    .item {
      display: block; text-decoration: none; color: inherit;
      background: var(--card); border: 1px solid var(--border); border-radius: 14px;
      overflow: hidden; cursor: pointer;
      transition: transform .15s, box-shadow .15s;
    }
    .item:hover { transform: translateY(-2px); box-shadow: 0 12px 28px rgba(0,0,0,.08); }
    .item img { display: block; width: 100%; aspect-ratio: 4/3; object-fit: cover; background: #f1f5f9; }
    .item-body { padding: 14px 16px 16px; }
    .item-name { margin: 0 0 4px; font-size: 17px; font-weight: 600; }
    .item-price { margin: 0; color: var(--brand); font-weight: 600; }
    .item-progress { margin-top: 10px; height: 6px; background: #f1f5f9; border-radius: 3px; overflow: hidden; }
    .item-progress > i { display: block; height: 100%; background: var(--brand); transition: width .3s; }
    .item-meta { margin: 8px 0 0; font-size: 13px; color: var(--muted); }
    .empty { text-align: center; padding: 48px 20px; color: var(--muted); }
  </style>
</head>
<body>
  <div class="container">
    <header>
      <h1>Sponsor our school</h1>
      <p>Pick an item to sponsor — every gift makes a difference.</p>
      <button class="donate-btn" data-donate="YOUR_PUBLIC_KEY">
        ❤ Donate any amount
      </button>
    </header>

    <main>
      <div id="items" class="grid">
        <div class="empty">Loading items…</div>
      </div>
    </main>
  </div>

  <script>
    const PUBLIC_KEY = 'YOUR_PUBLIC_KEY'   // ← from /dashboard/school/donor-board
    const WIDGET_SLUG = 'main'             // ← or your specific board's slug

    async function loadItems() {
      const url = `https://chinuchapp.com/api/donor-board/${PUBLIC_KEY}/items?widget=${WIDGET_SLUG}`
      const res = await fetch(url)
      if (!res.ok) throw new Error('Failed to load')
      return res.json()
    }

    function renderItems({ items }) {
      const root = document.getElementById('items')
      if (!items.length) {
        root.innerHTML = '<div class="empty">No items yet — check back soon.</div>'
        return
      }
      root.innerHTML = items.map(it => {
        const goal     = it.quantity_goal
        const done     = it.quantity_sponsored || 0
        const pct      = goal ? Math.min(100, Math.round(done / goal * 100)) : 0
        const progress = goal
          ? `<div class="item-progress"><i style="width:${pct}%"></i></div>
             <p class="item-meta">${done} of ${goal} sponsored</p>`
          : ''
        return `
          <a href="#" class="item"
             data-donate="${PUBLIC_KEY}"
             data-item="${it.id}">
            <img src="${it.image_url || ''}" alt="" loading="lazy">
            <div class="item-body">
              <h3 class="item-name">${escapeHtml(it.name)}</h3>
              <p class="item-price">$${it.suggested_amount} suggested</p>
              ${progress}
            </div>
          </a>
        `
      }).join('')

      // The script's MutationObserver auto-binds, but call attach() to be safe.
      window.ChinuchDonate?.attach()
    }

    function escapeHtml(s) {
      return String(s ?? '').replace(/[&<>"']/g, c => ({
        '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
      })[c])
    }

    // Analytics hook (optional)
    document.addEventListener('chinuchDonateSuccess', (e) => {
      console.log('Donation complete', e.detail)
    })

    loadItems().then(renderItems).catch(err => {
      document.getElementById('items').innerHTML =
        '<div class="empty">Couldn’t load items — please refresh.</div>'
      console.error(err)
    })
  </script>

  <!-- One script tag — auto-binds every [data-donate] on the page -->
  <script src="https://chinuchapp.com/donate-button.js" async></script>
</body>
</html>

What this page does

  • Header with a generic "Donate any amount" button.
  • Auto-fetches your items from the public API and renders a responsive card grid (image, name, suggested amount, progress bar).
  • Every card is wrapped in an <a> with data-donate + data-item so clicking opens the targeted donate modal.
  • Logs successful donations via the chinuchDonateSuccess event — wire your own analytics here.

Security & Limits

  • The publicKey is safe to expose on your website. It scopes everything to your school but doesn't grant write access to anything sensitive.
  • The donate iframe runs on chinuchapp.com with explicit X-Frame-Options: ALLOWALL + frame-ancestors *, so it embeds on any origin.
  • Rate limit: 10 requests / minute / IP for write endpoints (/donate, /pledge). Reads are unlimited.
  • Stripe payments go directly to your school's connected Stripe account — funds never pass through the platform.
  • Donor PII (email, phone) is stored encrypted-at-rest and is never returned by the public catalog endpoints, only in the admin dashboard.
  • The postMessage listener in donate-button.js only acts on four message types: donor-board-resize, donate-button-success, donate-button-close, donate-button-model-loaded.
  • Apply Content Security Policy carefully if you have one — the modal needs frame-src https://chinuchapp.com and script-src https://chinuchapp.com.

Troubleshooting

Click does nothing

Open DevTools → Console. Verify the script loaded (no 404 on donate-button.js) and window.ChinuchDonate.__loaded === true. Make sure the trigger element has data-donate with a value (not empty).

Modal opens but the form says "not available"

The widget is inactive, or the item/category id doesn't belong to your school. Open the donor-board admin and confirm: the widget is Active; the item is Active; and the slug/IDs match what's in your embed code.

Modal scrolls weirdly / iframe not sizing

The iframe auto-sizes via postMessage; it caps at 90vh. If your site has a global CSS rule like iframe { height: auto; } it can clash. Scope your styles or use [data-chinuch-donate-overlay] iframe to override.

CSP blocks the script

Add https://chinuchapp.com to script-src and frame-src.

"This item is fully sponsored"

The item's quantity_sponsored has reached quantity_goal. Bump the goal in the admin or accept that the campaign is done.

AI Agent Checklist

If an AI agent (or a developer) is using this doc to scaffold a donate page, here's the minimum viable list of things to do correctly. Treat this as the spec.

  1. Ask the user for their publicKey. If they don't know it, send them to /dashboard/school/donor-board → Settings → Embed Code.
  2. Decide what kind of donate target is needed:
    • Generic donate? Use data-donate only.
    • Specific item? Add data-item.
    • Whole category? Add data-category.
    • A different board? Add data-widget="slug".
    • Pre-set amount? Add data-amount="N".
  3. Always end the page with exactly one <script src="https://chinuchapp.com/donate-button.js" async></script>. Never inline this script.
  4. If rendering a list of items, fetch them at runtime from GET /api/donor-board/<publicKey>/items. Do not hard-code item ids into HTML.
  5. Escape any user-supplied strings before injecting into HTML (it.name, it.description). The full-page example above shows a safe escapeHtml().
  6. Listen for chinuchDonateSuccess if you need analytics or a redirect after the donation completes.
  7. Don't try to reuse /embed/<key>/donate inside your own iframe — let donate-button.js manage the modal so resize, ESC, and postMessage handlers all work.
  8. Test with a real publicKey — placeholder strings will return 404 School not found.

Minimum HTML to verify the script works

<button data-donate="YOUR_PUBLIC_KEY">Donate</button>
<script src="https://chinuchapp.com/donate-button.js" async></script>