@usethrottle/quotes 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # @usethrottle/quotes
2
+
3
+ Buyer-facing React components, hooks and a same-origin server proxy for
4
+ [Throttle](https://usethrottle.dev) B2B quotes — the hosted quote link and the
5
+ RFQ request form.
6
+
7
+ This is the **buyer** half of quotes. Merchant-side operations (create, price,
8
+ issue, revise, record acceptance) live in
9
+ [`@usethrottle/api-client`](https://www.npmjs.com/package/@usethrottle/api-client)'s
10
+ `QuotesService`, which needs a secret key and must stay server-side.
11
+
12
+ ```bash
13
+ npm install @usethrottle/quotes
14
+ ```
15
+
16
+ ## The buyer quote page
17
+
18
+ A quote link is `/(your route)/:token`, where the token is the `qtl_…` value
19
+ from the buyer's email. The token is the only credential — there is no API key
20
+ in the browser.
21
+
22
+ ```tsx
23
+ import { QuoteView } from '@usethrottle/quotes';
24
+
25
+ export default function QuotePage({ params }: { params: { token: string } }) {
26
+ return <QuoteView token={params.token} />;
27
+ }
28
+ ```
29
+
30
+ `QuoteView` renders line items with optional toggles and editable quantities,
31
+ live totals, revision history, the comment thread, and the accept /
32
+ request-changes / decline actions. On acceptance it sends the buyer to Throttle
33
+ hosted checkout.
34
+
35
+ The default markup is intentionally plain. For your own presentation, use the
36
+ hook and render whatever you like:
37
+
38
+ ```tsx
39
+ import { useQuote, formatMoney } from '@usethrottle/quotes';
40
+
41
+ function MyQuote({ token }: { token: string }) {
42
+ const { quote, total, selections, setQuantity, setSelected, accept, error } = useQuote(token);
43
+ if (!quote) return null;
44
+
45
+ return (
46
+ <>
47
+ {quote.currentRevision?.items.map((item) => (
48
+ <Row
49
+ key={item.id}
50
+ item={item}
51
+ state={selections[item.id]}
52
+ onQty={(q) => setQuantity(item.id, q)}
53
+ onToggle={(on) => setSelected(item.id, on)}
54
+ />
55
+ ))}
56
+ <strong>{formatMoney(total, quote.currency)}</strong>
57
+ <button onClick={() => accept({ acceptedByName: 'Ada', acceptedByEmail: 'ada@example.com' })}>
58
+ Accept
59
+ </button>
60
+ </>
61
+ );
62
+ }
63
+ ```
64
+
65
+ `useQuote` fires the view beacon once per mount, seeds selections from the
66
+ revision's own defaults, and computes `total` with **the same rule the server
67
+ applies at acceptance** — including scaling a line's tax and discount pro-rata
68
+ when the buyer changes its quantity. Keep using `total` rather than summing the
69
+ lines yourself, or the number you show may not be the number charged.
70
+
71
+ ## Errors worth handling
72
+
73
+ Everything throws `ThrottleQuotesError` carrying the API's own `code`:
74
+
75
+ | code | meaning |
76
+ | --- | --- |
77
+ | `revision_superseded` | A newer revision was issued. `error.currentRevisionId` names it; `useQuote` auto-refreshes. |
78
+ | `quote_expired` | The offer lapsed. Show "request updated pricing". |
79
+ | `invalid_item_selection` | A locked line's quantity was changed, a required line deselected, or an unknown item sent. |
80
+ | `accept_in_progress` | A concurrent accept won the race; retry shortly. |
81
+ | `not_found` | Unknown or rotated token, or a draft/archived quote. |
82
+
83
+ ```ts
84
+ import { ThrottleQuotesError } from '@usethrottle/quotes';
85
+
86
+ try {
87
+ await accept({ acceptedByName, acceptedByEmail });
88
+ } catch (e) {
89
+ if (e instanceof ThrottleQuotesError && e.isExpired) showExpiredNotice();
90
+ }
91
+ ```
92
+
93
+ ## RFQ request form
94
+
95
+ ```tsx
96
+ import { QuoteRequestForm } from '@usethrottle/quotes';
97
+
98
+ <QuoteRequestForm formToken={params.formToken} />;
99
+ ```
100
+
101
+ Bot protection is wired in: the honeypot field and the mount-time anchor that
102
+ feeds the API's minimum-fill-time check. A honeypot hit returns a
103
+ success-shaped response and creates nothing — by design, so a bot cannot learn
104
+ it was caught. Treat submission success as "accepted", not "created".
105
+
106
+ If the form requires Cloudflare Turnstile, render the widget yourself and pass
107
+ its token — this package pulls in no third-party script:
108
+
109
+ ```tsx
110
+ <QuoteRequestForm formToken={token} turnstileToken={turnstileToken} />
111
+ ```
112
+
113
+ ## Same-origin proxy (optional)
114
+
115
+ Quote links work fine calling Throttle directly. Proxy through your own origin
116
+ if you'd rather not have a third-party host in the address bar or CSP:
117
+
118
+ ```ts
119
+ // app/api/throttle/[...path]/route.ts
120
+ import { createQuoteProxyHandler } from '@usethrottle/quotes/server';
121
+
122
+ const handler = createQuoteProxyHandler();
123
+ export { handler as GET, handler as POST };
124
+ ```
125
+
126
+ ```tsx
127
+ <QuoteView token={token} options={{ baseUrl: 'https://yourshop.com/api/throttle' }} />
128
+ ```
129
+
130
+ The proxy forwards no API key — the buyer's token is the only credential. It
131
+ allow-lists the public quote paths, so it cannot be pointed at a merchant
132
+ endpoint, and forwards `x-forwarded-for` so acceptance evidence and per-IP
133
+ limits attribute to the real buyer.
134
+
135
+ ## Payment terms
136
+
137
+ A quote carries one of three terms, on `currentRevision.paymentTerms`:
138
+
139
+ - `pay_in_full` — card at checkout
140
+ - `net_terms` — invoice due in `netN` days
141
+ - `deposit_balance` — `depositAmount` charged today by card, the remainder
142
+ invoiced `balanceNetN` days out
143
+
144
+ `depositAmount` is frozen when the rep issues the quote, so it does not move
145
+ when the buyer edits quantities. Selections that would drop the total to or
146
+ below the deposit are rejected with `invalid_item_selection`.
147
+
148
+ ## License
149
+
150
+ MIT