@tipitip/embed
v0.1.1
Published
Drop-in React component for per-paragraph cUSD tipping on Celo. Add a tippable article block to any blog with two lines of code.
Maintainers
Readme
@tipitip/embed
Drop-in React component for embedding a TipiTip article in any blog, newsletter, or React app. Each paragraph gets a live tip counter pulled directly from the on-chain TipJar contract on Celo Mainnet.
Add tippable paragraphs to your existing blog with two lines of code — no wallet integration in your codebase, no smart-contract knowledge needed. The host stays where their audience already is (Substack, dev.to, personal Next.js site, WordPress, etc.).
import { TipParagraphs } from "@tipitip/embed";
export default function MyArticle() {
return <TipParagraphs articleId="0x73e8…" />;
}Why use this
| If you're already writing on... | What this embed does for you | |---|---| | Substack / Ghost / Medium | Add an iframe wrapper that lets readers tip you in cUSD without ever leaving the article page (tip transactions happen on tipitip-sable.vercel.app in a new tab; tip counts stream back to your post). | | dev.to / Hashnode / WordPress | Same as above — drop the component into your custom code block and ship. | | Your own Next.js / Vite / Remix blog | Native React install, zero config. Live tip counters under each paragraph. |
You publish on TipiTip once (free, ~10 seconds), get an articleId, then embed the same articleId everywhere. Every paragraph carries its own on-chain identity, so tips made via the embed and tips made on tipitip-sable.vercel.app aggregate to the same balance.
Install
pnpm add @tipitip/embed
# or
npm i @tipitip/embed
# or
yarn add @tipitip/embedPeer dependencies you must already have: react >= 18 and react-dom >= 18. No viem, no wagmi, no @noble/hashes in your bundle. The embed is intentionally dependency-light so it loads cleanly inside MDX, Substack custom HTML, and other constrained surfaces.
Usage
1. Basic
import { TipParagraphs } from "@tipitip/embed";
export function ArticleEmbed() {
return (
<TipParagraphs articleId="0x73e89882ff0c550111e5b4b5a1967582bdda9cb8000000000000000000000000" />
);
}2. Custom origin (for testing against a staging deploy)
<TipParagraphs
articleId="0x…"
baseUrl="https://my-tipitip-fork.vercel.app"
/>3. Read from Celo Sepolia (testnet) instead of Mainnet
<TipParagraphs articleId="0x…" chainId={11142220} />4. Faster polling
{/* Refresh tip counters every 10s instead of the default 30s */}
<TipParagraphs articleId="0x…" pollIntervalMs={10_000} />
{/* Disable polling — counters only update on mount */}
<TipParagraphs articleId="0x…" pollIntervalMs={0} />5. Customize the loading + error states
<TipParagraphs
articleId="0x…"
loadingFallback={<MySpinner />}
errorFallback={<MyEmptyState />}
/>6. Style the wrapper
<TipParagraphs
articleId="0x…"
className="prose prose-lg dark:prose-invert"
style={{ maxWidth: 720, margin: "0 auto" }}
/>The embed renders semantic HTML (<article> + <section> per paragraph + <a> for tip buttons) and ships its own minimal inline styles. You can override anything via class selectors:
.tipitip-embed { /* outer wrapper */ }
.tipitip-embed__paragraph { /* one paragraph block */ }
.tipitip-embed__body { /* rendered markdown */ }
.tipitip-embed__tip { /* heart counter button */ }Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| articleId | `0x${string}` | yes | — | The 32-byte article id registered on-chain. Get one by publishing an article at tipitip-sable.vercel.app/write. |
| baseUrl | string | no | https://tipitip-sable.vercel.app | Origin serving the TipiTip API. |
| chainId | 42220 \| 11142220 | no | 42220 | Which Celo network to read tip events from. |
| pollIntervalMs | number | no | 30000 | How often to refresh tip counts. Set 0 to disable. |
| loadingFallback | React.ReactNode | no | "Loading article…" | Rendered while the body is fetching. |
| errorFallback | React.ReactNode | no | "Failed to load article: …" | Rendered if the body fetch fails. |
| className | string | no | — | Applied to the outer <article>. |
| style | React.CSSProperties | no | — | Inline styles on the outer <article>. |
How tipping actually flows
This is a read-and-redirect embed. The clickable heart counter under each paragraph is a deep link to the canonical TipiTip article at {baseUrl}/a/{articleId}#p-{N}. When the reader clicks it:
- A new tab opens to TipiTip with the targeted paragraph scrolled into view.
- The reader connects their Celo wallet (MiniPay is auto-detected — no popup).
- They approve a one-time cUSD allowance, then tap the heart to fire
tipParagraph(...). - The on-chain
Tippedevent is emitted with the sameparagraphKeyyour embed already knows about. - Within 30 seconds the embedded counter on your blog reflects the new tip.
This split keeps your embed dependency-free and your wallet-aware UI on the verified TipiTip domain — your readers' trust signals (verified contract, brand recognition, multi-wallet support) all live at the canonical destination.
A future v0.2 will add an opt-in <TipParagraphsConnected> component that lifts the entire flow inline using a wagmi peer dependency, for hosts that already ship a wallet stack.
How tip counts get aggregated
The embed calls GET {baseUrl}/api/tip-stats/{articleId}?chainId={chainId}. The TipiTip server:
- Reads the article body from decentralized storage (Vercel Blob).
- Re-splits it with the exact same algorithm the embed uses (
splitParagraphs). - Scans the last ~200,000 blocks of
Tippedevents on the configured network. - Maps each event's
paragraphKeyback to the corresponding paragraph index. - Returns
{ paragraphs: { "0": { count, total }, "2": { count, total }, ... } }.
The endpoint is public, CORS-permissive, and cached for 30 seconds at the edge.
Caveats and limits
- Body edits. If the author edits the article body so a paragraph's text changes, old tips on that paragraph stay claimable on-chain by the author but stop showing up under the new text. That's intentional — paragraphs are tipped because of what they say.
- Block range. The default scan window is the last ~3-4 days of blocks. Tips made before that aren't reflected (yet — a subgraph migration is on the roadmap for unbounded history once volume warrants it).
- No SSR. The component uses
useEffectfor both the body fetch and the stats poll, so it always renders the loading state on the server. If you need SSR-rendered article text, fetch the body yourself from{baseUrl}/api/articles/{articleId}and pass it through.
Demo
A worked example with a real on-chain article:
<TipParagraphs articleId="0x7ff67b58d8cbc9deaa11b8e1f6cf95dba0e2f97d8d2f8a8a9e51ba4e6df8a25f" />The corresponding rendering on the canonical site: https://tipitip-sable.vercel.app/a/0x7ff67b58d8cbc9deaa11b8e1f6cf95dba0e2f97d8d2f8a8a9e51ba4e6df8a25f
License
MIT © TipiTip
