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.
The donation system has three pieces:
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.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.
| Concept | What it is | URL identifier |
|---|---|---|
| School | Your organization. Has a public_key that's safe to put on your website. | publicKey |
| Widget / Board | A standalone donor-board campaign (e.g. "Building Fund", "Annual Campaign"). Has its own theme, payment methods, slots, categories, and items. | slug |
| Sponsorship Slot | A time period that one donor sponsors (a week, day, or month). Single-sponsor. | slot id |
| Donation Category | A grouping of items (e.g. "Furniture", "Books"). | category id |
| Donation Item | A 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 |
| Pledge | The donor's record (name, amount, dedication, payment status). One pledge per slot, or 1+ per item. | pledge id |
publicKey pre-filled.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.
https://chinuchapp.com/donate-button.js is a single-file script (no dependencies, IIFE-wrapped, ~7 KB) that:
data-donate./embed/<publicKey>/donate.postMessage events from the iframe (resize, success, close).donate-button-close message from the iframe.window.ChinuchDonate for programmatic use.<!-- 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).
Put these on any element — <button>, <a>, <div>, <img> — and the script will turn it into a donate trigger.
| Attribute | Required | Purpose |
|---|---|---|
| data-donate | Yes | Your school's public_key. Marks the element as a donate trigger. |
| data-item | No | Donate to a specific donation item. Form pre-fills with the item's name, image, suggested amount, and progress bar. |
| data-category | No | Donate to an entire category. Donor picks any amount toward that category. |
| data-widget | No | Widget slug (e.g. building-fund). Useful for generic donations to a particular board. Defaults to main. |
| data-amount | No | Pre-fills the donation amount in dollars (e.g. data-amount="180"). Donor can still change it. |
<!-- 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.
Once the script is loaded, you can open and close the modal from your own JavaScript via window.ChinuchDonate.
Opens the donate modal. opts object:
| Field | Type | Note |
|---|---|---|
| publicKey | string | Required. |
| itemId | string? | Donation item UUID. |
| categoryId | string? | Category UUID. |
| widgetSlug | string? | Widget slug. Default main. |
| amount | number? | Pre-filled dollar amount. |
Closes the modal and resets the iframe to about:blank.
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
})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 name | When | event.detail |
|---|---|---|
| chinuchDonateSuccess | After a pledge or payment is recorded. | { type, publicKey, amount, itemId, categoryId } |
| chinuchDonateModelLoaded | Fires 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.
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.
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:
image_url.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).
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.image_url automatically if the GLB or its textures fail to load (network hiccup, CORS, decoder error). Donors always see something.chinuchDonateModelLoaded event documented above.The /items and /items/[itemId] endpoints return four extra fields when an item has a model:
| Field | Type | Note |
|---|---|---|
| glb_url | string | null | Public URL of the GLB. Only populated once model_status === 'ready'. |
| model_thumbnail_url | string | null | Provider-rendered preview PNG, used as the model's poster while the GLB loads. |
| model_status | string | One of idle, generating_catalog, generating_model, ready, error. |
| model_provider | string | null | Which provider built the GLB. Currently meshy; reserved for future routing. |
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.
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.
/* 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>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.
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 }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>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()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 zero400 — Only N unit(s) remaining for this item400 — This slot has already been sponsored400 — Amount must be $X (fixed-amount widgets)404 — School not found /Item not found429 — Too many requests (rate limit)Slot-only convenience endpoint. Identical to /donate but requires slot_id in the body.
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"
}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_..."
}birthdayanniversaryyahrzeitrefuahzechusin_memorylilui_nishmasotherFetch 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>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)
})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))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)
}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.
// 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"
/>
</>
)
}// 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><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>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 => ({
'&':'&','<':'<','>':'>','"':'"',"'":'''
})[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
<a> with data-donate + data-item so clicking opens the targeted donate modal.chinuchDonateSuccess event — wire your own analytics here.publicKey is safe to expose on your website. It scopes everything to your school but doesn't grant write access to anything sensitive.chinuchapp.com with explicit X-Frame-Options: ALLOWALL + frame-ancestors *, so it embeds on any origin./donate, /pledge). Reads are unlimited.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.frame-src https://chinuchapp.com and script-src https://chinuchapp.com.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.
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.
data-donate only.data-item.data-category.data-widget="slug".data-amount="N".<script src="https://chinuchapp.com/donate-button.js" async></script>. Never inline this script.GET /api/donor-board/<publicKey>/items. Do not hard-code item ids into HTML.it.name, it.description). The full-page example above shows a safe escapeHtml().chinuchDonateSuccess if you need analytics or a redirect after the donation completes./embed/<key>/donate inside your own iframe — let donate-button.js manage the modal so resize, ESC, and postMessage handlers all work.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>