@usethrottle/quotes
v0.3.0
Published
Buyer-facing React components, hooks and server proxy for Throttle B2B quotes — hosted quote links and RFQ request forms.
Maintainers
Readme
@usethrottle/quotes
Buyer-facing React components, hooks and a same-origin server proxy for Throttle B2B quotes — the hosted quote link and the RFQ request form.
This is the buyer half of quotes. Merchant-side operations (create, price,
issue, revise, record acceptance) live in
@usethrottle/api-client's
QuotesService, which needs a secret key and must stay server-side.
npm install @usethrottle/quotesThe buyer quote page
A quote link is /(your route)/:token, where the token is the qtl_… value
from the buyer's email. The token is the only credential — there is no API key
in the browser.
import { QuoteView } from '@usethrottle/quotes';
export default function QuotePage({ params }: { params: { token: string } }) {
return <QuoteView token={params.token} />;
}QuoteView renders line items with optional toggles and editable quantities,
live totals, revision history, the comment thread, and the accept /
request-changes / decline actions. On acceptance it sends the buyer to Throttle
hosted checkout.
The default markup is intentionally plain. For your own presentation, use the hook and render whatever you like:
import { useQuote, formatMoney } from '@usethrottle/quotes';
function MyQuote({ token }: { token: string }) {
const { quote, total, selections, setQuantity, setSelected, accept, error } = useQuote(token);
if (!quote) return null;
return (
<>
{quote.currentRevision?.items.map((item) => (
<Row
key={item.id}
item={item}
state={selections[item.id]}
onQty={(q) => setQuantity(item.id, q)}
onToggle={(on) => setSelected(item.id, on)}
/>
))}
<strong>{formatMoney(total, quote.currency)}</strong>
<button onClick={() => accept({ acceptedByName: 'Ada', acceptedByEmail: '[email protected]' })}>
Accept
</button>
</>
);
}useQuote fires the view beacon once per mount, seeds selections from the
revision's own defaults, and computes total with the same rule the server
applies at acceptance — including scaling a line's tax and discount pro-rata
when the buyer changes its quantity. Keep using total rather than summing the
lines yourself, or the number you show may not be the number charged.
Errors worth handling
Everything throws ThrottleQuotesError carrying the API's own code:
| code | meaning |
| --- | --- |
| revision_superseded | A newer revision was issued. error.currentRevisionId names it; useQuote auto-refreshes. |
| quote_expired | The offer lapsed. Show "request updated pricing". |
| invalid_item_selection | A locked line's quantity was changed, a required line deselected, or an unknown item sent. |
| accept_in_progress | A concurrent accept won the race; retry shortly. |
| not_found | Unknown or rotated token, or a draft/archived quote. |
import { ThrottleQuotesError } from '@usethrottle/quotes';
try {
await accept({ acceptedByName, acceptedByEmail });
} catch (e) {
if (e instanceof ThrottleQuotesError && e.isExpired) showExpiredNotice();
}RFQ request form
import { QuoteRequestForm } from '@usethrottle/quotes';
<QuoteRequestForm formToken={params.formToken} />;Bot protection is wired in: the honeypot field and the mount-time anchor that feeds the API's minimum-fill-time check. A honeypot hit returns a success-shaped response and creates nothing — by design, so a bot cannot learn it was caught. Treat submission success as "accepted", not "created".
If the form requires Cloudflare Turnstile, render the widget yourself and pass its token — this package pulls in no third-party script:
<QuoteRequestForm formToken={token} turnstileToken={turnstileToken} />Add to Quote (quote cart)
Buyers who want several things quoted can collect them while they browse. The cart persists across page navigation and merges quantities when the same SKU is added twice. Throttle stores no catalog, so lines come from your own product data — the same arrangement as the shopping cart.
import { useQuoteCart, QuoteRequestForm } from '@usethrottle/quotes';
function AddToQuote({ product }) {
const { add, count } = useQuoteCart();
return (
<button
onClick={() =>
add({ name: product.title, sku: product.sku, referenceId: product.id, quantity: 1 })
}
>
Add to quote ({count})
</button>
);
}
// On your request page: submit everything they collected as one RFQ.
function RequestPage({ formToken }) {
const { lines } = useQuoteCart();
return <QuoteRequestForm formToken={formToken} items={lines} />;
}A request needs items, a written message, or both. Lines arrive on the
merchant's working revision unpriced: targetUnitPrice is recorded as the
buyer's ask and shown to the rep, and never becomes the quoted price — nothing
sent from a browser can set what a merchant charges.
Outside React, createQuoteCart() exposes the same add/update/remove/
subscribe/toRequestItems surface for a plain-JS storefront.
Same-origin proxy (optional)
Quote links work fine calling Throttle directly. Proxy through your own origin if you'd rather not have a third-party host in the address bar or CSP:
// app/api/throttle/[...path]/route.ts
import { createQuoteProxyHandler } from '@usethrottle/quotes/server';
const handler = createQuoteProxyHandler();
export { handler as GET, handler as POST };<QuoteView token={token} options={{ baseUrl: 'https://yourshop.com/api/throttle' }} />The proxy forwards no API key — the buyer's token is the only credential. It
allow-lists the public quote paths, so it cannot be pointed at a merchant
endpoint, and forwards x-forwarded-for so acceptance evidence and per-IP
limits attribute to the real buyer.
Payment terms
A quote carries one of three terms, on currentRevision.paymentTerms:
pay_in_full— card at checkoutnet_terms— invoice due innetNdaysdeposit_balance—depositAmountcharged today by card, the remainder invoicedbalanceNetNdays out
depositAmount is frozen when the rep issues the quote, so it does not move
when the buyer edits quantities. Selections that would drop the total to or
below the deposit are rejected with invalid_item_selection.
License
MIT
