@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 +150 -0
- package/dist/index.cjs +736 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +354 -0
- package/dist/index.d.ts +354 -0
- package/dist/index.js +700 -0
- package/dist/index.js.map +1 -0
- package/dist/server.cjs +101 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +25 -0
- package/dist/server.d.ts +25 -0
- package/dist/server.js +76 -0
- package/dist/server.js.map +1 -0
- package/package.json +63 -0
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
|