Quotes

React Package · @usethrottle/quotes

Buyer-facing components, hooks, and a same-origin proxy for the hosted quote link and the RFQ request form — so you can serve the buyer experience from your own storefront instead of sending people to Throttle's hosted page.

This is the buyer half only
Everything here is authorized by the qtl_ capability token in the buyer's URL — there is no API key, because this runs in the browser. Merchant operations (create, price, issue, revise, record acceptance) need an sk_* key and live in @usethrottle/api-client's QuotesService , which must stay server-side.

Install

bash
pnpm add @usethrottle/quotes

React 18+ is a peer dependency. The package ships ESM and CJS builds with type declarations, and exposes two entrypoints: the root (browser) and /server (the optional proxy).

The buyer quote page

Mount QuoteView on a route that receives the token from the buyer's emailed link.

tsx
// app/q/[token]/page.tsx
import { QuoteView } from '@usethrottle/quotes';
export default function QuotePage({ params }: { params: { token: string } }) {
  return <QuoteView token={params.token} />;
}

It renders line items with optional toggles and editable quantities, live totals, revision history, the comment thread, a PDF link, and the accept / request-changes / decline actions. On acceptance it sends the buyer to Throttle hosted checkout.

The default markup is deliberately plain — it is meant to be restyled or replaced. For your own presentation, use the hook directly.

useQuote(token, options?)

tsx
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)}
        />
      ))}
      {/* Always render `total` — never sum the lines yourself. */}
      <strong>{formatMoney(total, quote.currency)}</strong>
      <button
        onClick={async () => {
          const { checkoutUrl } = await accept({
            acceptedByName: 'Ada Lovelace',
            acceptedByEmail: '[email protected]',
          });
          window.location.href = checkoutUrl;
        }}
      >
        Accept &amp; checkout
      </button>
    </>
  );
}

The hook owns the behaviour that is easy to get wrong:

  • Fires the view beacon once per mount, so the merchant sees first viewed telemetry. The API throttles the resulting quote.viewed webhook to one per revision per 6 hours.
  • Seeds selections from the revision, so a re-issue that adds or locks lines resets the buyer to the new offer.
  • Refetches automatically on revision_superseded — the rep re-issued while the buyer was reading.
  • Sends only the selections the rep actually allows: locked lines and non-optional lines are never included in the acceptance payload.
Always render `total` — never sum the lines yourself
total is computed 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. Rolling your own sum risks showing the buyer one number and charging another.

Errors

Everything throws ThrottleQuotesError, which extends the shared ThrottleError and carries the API's own code.

ts
import { ThrottleQuotesError } from '@usethrottle/quotes';
try {
  await accept({ acceptedByName, acceptedByEmail });
} catch (e) {
  if (e instanceof ThrottleQuotesError) {
    if (e.isExpired) return showExpiredNotice();
    // useQuote already refetched; e.currentRevisionId names the live revision.
    if (e.isSuperseded) return showRefreshedPricing();
    console.error(e.code, e.statusCode, e.message);
  }
}
code meaning
revision_superseded A newer revision was issued. currentRevisionId names it; useQuote auto-refreshes.
quote_expired The offer lapsed (410). Prompt the buyer to request updated pricing.
invalid_item_selection A locked line's quantity was changed, a required line deselected, an unknown item sent, or (for deposit terms) the selection fell to or below the deposit.
accept_in_progress A concurrent acceptance won the race. Retry shortly.
not_found Unknown or rotated token, or a draft/archived quote.

RFQ request form

tsx
// app/q/request/[formToken]/page.tsx
import { QuoteRequestForm } from '@usethrottle/quotes';
export default function RequestPage({ params }: { params: { formToken: string } }) {
  return <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 as turnstileToken — this package pulls in no third-party script. A form that requires Turnstile on a store where it is not configured is refused with turnstile_not_configured rather than silently accepted.

Same-origin proxy · /server entrypoint

Quote links work fine calling Throttle directly — the token is the credential. Proxy through your own origin if you would rather not have a third-party host in the address bar or CSP.

ts
// app/api/throttle/[...path]/route.ts
import { createQuoteProxyHandler } from '@usethrottle/quotes/server';
const handler = createQuoteProxyHandler();
export { handler as GET, handler as POST };
tsx
<QuoteView token={token} options={{ baseUrl: 'https://yourshop.com/api/throttle' }} />

The proxy forwards no API key. It allow-lists the public quote paths, so it cannot be pointed at a merchant endpoint; rejects traversal and any method other than GET/POST; passes the PDF 302 straight through instead of buffering the file; 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 modes on currentRevision.paymentTerms:

  • pay_in_full — card at checkout.
  • net_terms — invoice due in netN days.
  • deposit_balancedepositAmount charged today by card, the remainder invoiced balanceNetN days 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.