@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/dist/index.js
ADDED
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
import { ThrottleError } from "@usethrottle/errors";
|
|
3
|
+
var ThrottleQuotesError = class extends ThrottleError {
|
|
4
|
+
constructor(args) {
|
|
5
|
+
super(args);
|
|
6
|
+
this.name = "ThrottleQuotesError";
|
|
7
|
+
}
|
|
8
|
+
/** The offer moved on — reload and show the buyer the current revision. */
|
|
9
|
+
get isSuperseded() {
|
|
10
|
+
return this.code === "revision_superseded";
|
|
11
|
+
}
|
|
12
|
+
get isExpired() {
|
|
13
|
+
return this.code === "quote_expired";
|
|
14
|
+
}
|
|
15
|
+
/** Revision the server considers current, when it told us (spec §8.1). */
|
|
16
|
+
get currentRevisionId() {
|
|
17
|
+
const d = this.details;
|
|
18
|
+
return d?.currentRevisionId;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// src/client.ts
|
|
23
|
+
var DEFAULT_BASE = "https://api.usethrottle.dev";
|
|
24
|
+
var QuotesClient = class {
|
|
25
|
+
baseUrl;
|
|
26
|
+
fetchImpl;
|
|
27
|
+
timeoutMs;
|
|
28
|
+
constructor(opts = {}) {
|
|
29
|
+
const fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
30
|
+
if (!fetchImpl) throw new Error("QuotesClient: fetch is required");
|
|
31
|
+
this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE).replace(/\/+$/, "");
|
|
32
|
+
this.fetchImpl = fetchImpl;
|
|
33
|
+
this.timeoutMs = opts.timeoutMs ?? 3e4;
|
|
34
|
+
}
|
|
35
|
+
async request(method, path, body) {
|
|
36
|
+
const ctrl = new AbortController();
|
|
37
|
+
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
|
|
38
|
+
try {
|
|
39
|
+
const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
40
|
+
method,
|
|
41
|
+
headers: body === void 0 ? {} : { "content-type": "application/json" },
|
|
42
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) },
|
|
43
|
+
signal: ctrl.signal
|
|
44
|
+
});
|
|
45
|
+
const json = await res.json().catch(() => ({}));
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
throw new ThrottleQuotesError({
|
|
48
|
+
code: json.error?.code ?? "unknown_error",
|
|
49
|
+
message: json.error?.message ?? `HTTP ${res.status}`,
|
|
50
|
+
statusCode: res.status,
|
|
51
|
+
details: json.error?.details
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return json.data;
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Fetch the quote a buyer link points at. 404s for unknown/rotated tokens. */
|
|
60
|
+
getQuote(token) {
|
|
61
|
+
return this.request("GET", `/api/v1/quote-links/${encodeURIComponent(token)}`);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Tell the merchant the buyer opened the quote. Fire-and-forget: the server
|
|
65
|
+
* throttles the resulting webhook to one per revision per 6h, and a failed
|
|
66
|
+
* beacon must never break the page.
|
|
67
|
+
*/
|
|
68
|
+
async recordView(token) {
|
|
69
|
+
try {
|
|
70
|
+
await this.request("POST", `/api/v1/quote-links/${encodeURIComponent(token)}/view`, {});
|
|
71
|
+
} catch {
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
addComment(token, input) {
|
|
75
|
+
return this.request(
|
|
76
|
+
"POST",
|
|
77
|
+
`/api/v1/quote-links/${encodeURIComponent(token)}/comments`,
|
|
78
|
+
input
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
requestRevision(token, input = {}) {
|
|
82
|
+
return this.request(
|
|
83
|
+
"POST",
|
|
84
|
+
`/api/v1/quote-links/${encodeURIComponent(token)}/request-revision`,
|
|
85
|
+
input
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
decline(token, input = {}) {
|
|
89
|
+
return this.request(
|
|
90
|
+
"POST",
|
|
91
|
+
`/api/v1/quote-links/${encodeURIComponent(token)}/decline`,
|
|
92
|
+
input
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Accept and get the checkout URL to send the buyer to.
|
|
97
|
+
*
|
|
98
|
+
* Re-accepting the same revision is idempotent and replays the same URL.
|
|
99
|
+
* Accepting a superseded revision throws `revision_superseded` carrying
|
|
100
|
+
* `currentRevisionId`; an expired offer throws `quote_expired`.
|
|
101
|
+
*/
|
|
102
|
+
accept(token, input) {
|
|
103
|
+
return this.request(
|
|
104
|
+
"POST",
|
|
105
|
+
`/api/v1/quote-links/${encodeURIComponent(token)}/accept`,
|
|
106
|
+
input
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
/** URL of the PDF for the revision the buyer is looking at (server 302s to signed storage). */
|
|
110
|
+
pdfUrl(token) {
|
|
111
|
+
return `${this.baseUrl}/api/v1/quote-links/${encodeURIComponent(token)}/pdf`;
|
|
112
|
+
}
|
|
113
|
+
// ── RFQ intake ─────────────────────────────────────────────────────────────
|
|
114
|
+
getRequestForm(formToken) {
|
|
115
|
+
return this.request(
|
|
116
|
+
"GET",
|
|
117
|
+
`/api/v1/quote-request-forms/${encodeURIComponent(formToken)}/public`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Submit an RFQ. The response is deliberately thin (`{ submitted: true }`) —
|
|
122
|
+
* anonymous callers learn no internal identifiers. A submission caught by
|
|
123
|
+
* the honeypot returns the same shape without creating anything, so a bot
|
|
124
|
+
* cannot tell it was detected; treat success as "accepted", not "created".
|
|
125
|
+
*/
|
|
126
|
+
submitRequest(formToken, input) {
|
|
127
|
+
return this.request("POST", "/api/v1/quote-requests", {
|
|
128
|
+
formToken,
|
|
129
|
+
...input
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
function createQuotesClient(opts = {}) {
|
|
134
|
+
return new QuotesClient(opts);
|
|
135
|
+
}
|
|
136
|
+
function formatMoney(minorUnits, currency, locale) {
|
|
137
|
+
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(minorUnits / 100);
|
|
138
|
+
}
|
|
139
|
+
function scaleLineCharges(item, quantity) {
|
|
140
|
+
const base = item.quantity;
|
|
141
|
+
if (!base || base <= 0 || quantity === base) {
|
|
142
|
+
return { taxAmount: item.taxAmount, discountAmount: item.discountAmount };
|
|
143
|
+
}
|
|
144
|
+
const ratio = quantity / base;
|
|
145
|
+
return {
|
|
146
|
+
taxAmount: Math.round(item.taxAmount * ratio),
|
|
147
|
+
discountAmount: Math.round(item.discountAmount * ratio)
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function computeSelectionTotal(items, selections, shippingTotal = 0) {
|
|
151
|
+
let total = shippingTotal;
|
|
152
|
+
for (const item of items) {
|
|
153
|
+
const state = selections[item.id];
|
|
154
|
+
if (item.optional && state && !state.selected) continue;
|
|
155
|
+
const qty = state?.quantity ?? item.quantity;
|
|
156
|
+
const { taxAmount, discountAmount } = scaleLineCharges(item, qty);
|
|
157
|
+
total += item.unitPrice * qty + taxAmount - discountAmount;
|
|
158
|
+
}
|
|
159
|
+
return total;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// src/hooks.ts
|
|
163
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
164
|
+
function useQuote(token, options = {}) {
|
|
165
|
+
const client = useMemo(
|
|
166
|
+
() => new QuotesClient(options),
|
|
167
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
168
|
+
[options.baseUrl, options.timeoutMs]
|
|
169
|
+
);
|
|
170
|
+
const [quote, setQuote] = useState(null);
|
|
171
|
+
const [loading, setLoading] = useState(true);
|
|
172
|
+
const [submitting, setSubmitting] = useState(false);
|
|
173
|
+
const [error, setError] = useState(null);
|
|
174
|
+
const [selections, setSelections] = useState({});
|
|
175
|
+
const beaconSent = useRef(false);
|
|
176
|
+
const load = useCallback(async () => {
|
|
177
|
+
setLoading(true);
|
|
178
|
+
try {
|
|
179
|
+
const q = await client.getQuote(token);
|
|
180
|
+
setQuote(q);
|
|
181
|
+
setError(null);
|
|
182
|
+
const next = {};
|
|
183
|
+
for (const item of q.currentRevision?.items ?? []) {
|
|
184
|
+
next[item.id] = { selected: item.optional ? item.optionalSelected : true, quantity: item.quantity };
|
|
185
|
+
}
|
|
186
|
+
setSelections(next);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
setError(e);
|
|
189
|
+
} finally {
|
|
190
|
+
setLoading(false);
|
|
191
|
+
}
|
|
192
|
+
}, [client, token]);
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
void load();
|
|
195
|
+
}, [load]);
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (!quote || beaconSent.current) return;
|
|
198
|
+
beaconSent.current = true;
|
|
199
|
+
void client.recordView(token);
|
|
200
|
+
}, [client, quote, token]);
|
|
201
|
+
const setSelected = useCallback((itemId, selected) => {
|
|
202
|
+
setSelections((prev) => ({ ...prev, [itemId]: { ...prev[itemId] ?? { quantity: 1 }, selected } }));
|
|
203
|
+
}, []);
|
|
204
|
+
const setQuantity = useCallback((itemId, quantity) => {
|
|
205
|
+
setSelections((prev) => ({
|
|
206
|
+
...prev,
|
|
207
|
+
[itemId]: { ...prev[itemId] ?? { selected: true }, quantity: Math.max(1, Math.floor(quantity) || 1) }
|
|
208
|
+
}));
|
|
209
|
+
}, []);
|
|
210
|
+
const total = useMemo(
|
|
211
|
+
() => computeSelectionTotal(
|
|
212
|
+
quote?.currentRevision?.items ?? [],
|
|
213
|
+
selections,
|
|
214
|
+
quote?.currentRevision?.shippingTotal ?? 0
|
|
215
|
+
),
|
|
216
|
+
[quote, selections]
|
|
217
|
+
);
|
|
218
|
+
const buildSelections = useCallback(() => {
|
|
219
|
+
const items = quote?.currentRevision?.items ?? [];
|
|
220
|
+
const out = [];
|
|
221
|
+
for (const item of items) {
|
|
222
|
+
const state = selections[item.id];
|
|
223
|
+
if (!state) continue;
|
|
224
|
+
const sel = { itemId: item.id };
|
|
225
|
+
let include = false;
|
|
226
|
+
if (item.optional && state.selected !== item.optionalSelected) {
|
|
227
|
+
sel.selected = state.selected;
|
|
228
|
+
include = true;
|
|
229
|
+
}
|
|
230
|
+
if (item.qtyEditable && state.quantity !== item.quantity) {
|
|
231
|
+
sel.quantity = state.quantity;
|
|
232
|
+
include = true;
|
|
233
|
+
}
|
|
234
|
+
if (include) out.push(sel);
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}, [quote, selections]);
|
|
238
|
+
const guard = useCallback(
|
|
239
|
+
async (fn) => {
|
|
240
|
+
setSubmitting(true);
|
|
241
|
+
try {
|
|
242
|
+
const result = await fn();
|
|
243
|
+
setError(null);
|
|
244
|
+
return result;
|
|
245
|
+
} catch (e) {
|
|
246
|
+
const err = e;
|
|
247
|
+
setError(err);
|
|
248
|
+
if (err.isSuperseded) void load();
|
|
249
|
+
throw err;
|
|
250
|
+
} finally {
|
|
251
|
+
setSubmitting(false);
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
[load]
|
|
255
|
+
);
|
|
256
|
+
const accept = useCallback(
|
|
257
|
+
async (input) => {
|
|
258
|
+
const revisionId = quote?.currentRevision?.id;
|
|
259
|
+
if (!revisionId) throw new Error("useQuote: no current revision to accept");
|
|
260
|
+
const itemSelections = buildSelections();
|
|
261
|
+
const res = await guard(
|
|
262
|
+
() => client.accept(token, {
|
|
263
|
+
...input,
|
|
264
|
+
revisionId,
|
|
265
|
+
...itemSelections.length ? { itemSelections } : {}
|
|
266
|
+
})
|
|
267
|
+
);
|
|
268
|
+
return { checkoutUrl: res.checkoutUrl };
|
|
269
|
+
},
|
|
270
|
+
[buildSelections, client, guard, quote, token]
|
|
271
|
+
);
|
|
272
|
+
const requestRevision = useCallback(
|
|
273
|
+
async (message, authorName) => {
|
|
274
|
+
const q = await guard(() => client.requestRevision(token, { message, authorName }));
|
|
275
|
+
setQuote(q);
|
|
276
|
+
},
|
|
277
|
+
[client, guard, token]
|
|
278
|
+
);
|
|
279
|
+
const decline = useCallback(
|
|
280
|
+
async (reason, authorName) => {
|
|
281
|
+
const q = await guard(() => client.decline(token, { reason, authorName }));
|
|
282
|
+
setQuote(q);
|
|
283
|
+
},
|
|
284
|
+
[client, guard, token]
|
|
285
|
+
);
|
|
286
|
+
const addComment = useCallback(
|
|
287
|
+
async (body, authorName) => {
|
|
288
|
+
await guard(() => client.addComment(token, { body, authorName }));
|
|
289
|
+
await load();
|
|
290
|
+
},
|
|
291
|
+
[client, guard, load, token]
|
|
292
|
+
);
|
|
293
|
+
return {
|
|
294
|
+
quote,
|
|
295
|
+
loading,
|
|
296
|
+
error,
|
|
297
|
+
refresh: load,
|
|
298
|
+
selections,
|
|
299
|
+
setSelected,
|
|
300
|
+
setQuantity,
|
|
301
|
+
total,
|
|
302
|
+
accept,
|
|
303
|
+
requestRevision,
|
|
304
|
+
decline,
|
|
305
|
+
addComment,
|
|
306
|
+
pdfUrl: client.pdfUrl(token),
|
|
307
|
+
submitting
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
function useQuoteRequestForm(formToken, options = {}) {
|
|
311
|
+
const client = useMemo(
|
|
312
|
+
() => new QuotesClient(options),
|
|
313
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
314
|
+
[options.baseUrl, options.timeoutMs]
|
|
315
|
+
);
|
|
316
|
+
const [config, setConfig] = useState(null);
|
|
317
|
+
const [loading, setLoading] = useState(true);
|
|
318
|
+
const [submitting, setSubmitting] = useState(false);
|
|
319
|
+
const [submitted, setSubmitted] = useState(false);
|
|
320
|
+
const [error, setError] = useState(null);
|
|
321
|
+
const formLoadedAt = useRef(Date.now()).current;
|
|
322
|
+
useEffect(() => {
|
|
323
|
+
let cancelled = false;
|
|
324
|
+
(async () => {
|
|
325
|
+
try {
|
|
326
|
+
const cfg = await client.getRequestForm(formToken);
|
|
327
|
+
if (!cancelled) setConfig(cfg);
|
|
328
|
+
} catch (e) {
|
|
329
|
+
if (!cancelled) setError(e);
|
|
330
|
+
} finally {
|
|
331
|
+
if (!cancelled) setLoading(false);
|
|
332
|
+
}
|
|
333
|
+
})();
|
|
334
|
+
return () => {
|
|
335
|
+
cancelled = true;
|
|
336
|
+
};
|
|
337
|
+
}, [client, formToken]);
|
|
338
|
+
const submit = useCallback(
|
|
339
|
+
async (input) => {
|
|
340
|
+
setSubmitting(true);
|
|
341
|
+
try {
|
|
342
|
+
await client.submitRequest(formToken, { formLoadedAt, ...input });
|
|
343
|
+
setSubmitted(true);
|
|
344
|
+
setError(null);
|
|
345
|
+
} catch (e) {
|
|
346
|
+
setError(e);
|
|
347
|
+
throw e;
|
|
348
|
+
} finally {
|
|
349
|
+
setSubmitting(false);
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
[client, formLoadedAt, formToken]
|
|
353
|
+
);
|
|
354
|
+
return { config, loading, error, submitting, submitted, submit, formLoadedAt };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// src/QuoteView.tsx
|
|
358
|
+
import { useState as useState2 } from "react";
|
|
359
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
360
|
+
var row = { display: "flex", justifyContent: "space-between", gap: 16, padding: "8px 0" };
|
|
361
|
+
var muted = { color: "#666", fontSize: 13 };
|
|
362
|
+
function QuoteView({ token, options, onCheckout, loadingFallback, className }) {
|
|
363
|
+
const q = useQuote(token, options ?? {});
|
|
364
|
+
const [name, setName] = useState2("");
|
|
365
|
+
const [email, setEmail] = useState2("");
|
|
366
|
+
const [showAccept, setShowAccept] = useState2(false);
|
|
367
|
+
const [comment, setComment] = useState2("");
|
|
368
|
+
if (q.loading) return /* @__PURE__ */ jsx(Fragment, { children: loadingFallback ?? /* @__PURE__ */ jsx("p", { children: "Loading quote\u2026" }) });
|
|
369
|
+
if (!q.quote) {
|
|
370
|
+
return /* @__PURE__ */ jsx("p", { children: "This quote link is no longer available." });
|
|
371
|
+
}
|
|
372
|
+
const quote = q.quote;
|
|
373
|
+
const rev = quote.currentRevision;
|
|
374
|
+
const currency = quote.currency;
|
|
375
|
+
const ordered = quote.status === "ordered";
|
|
376
|
+
const canAct = quote.status === "ready";
|
|
377
|
+
const handleAccept = async () => {
|
|
378
|
+
const { checkoutUrl } = await q.accept({ acceptedByName: name, acceptedByEmail: email });
|
|
379
|
+
if (onCheckout) onCheckout(checkoutUrl);
|
|
380
|
+
else if (typeof window !== "undefined") window.location.href = checkoutUrl;
|
|
381
|
+
};
|
|
382
|
+
return /* @__PURE__ */ jsxs("div", { className, children: [
|
|
383
|
+
/* @__PURE__ */ jsxs("header", { children: [
|
|
384
|
+
quote.branding?.displayName ? /* @__PURE__ */ jsx("p", { style: muted, children: quote.branding.displayName }) : null,
|
|
385
|
+
/* @__PURE__ */ jsxs("h1", { children: [
|
|
386
|
+
"Quote ",
|
|
387
|
+
quote.quoteNumber
|
|
388
|
+
] }),
|
|
389
|
+
/* @__PURE__ */ jsx("p", { style: muted, children: statusLabel(quote.status) }),
|
|
390
|
+
quote.buyerReference ? /* @__PURE__ */ jsxs("p", { style: muted, children: [
|
|
391
|
+
"Your reference: ",
|
|
392
|
+
quote.buyerReference
|
|
393
|
+
] }) : null,
|
|
394
|
+
rev ? /* @__PURE__ */ jsxs("p", { style: muted, children: [
|
|
395
|
+
"Revision ",
|
|
396
|
+
rev.revisionNumber,
|
|
397
|
+
rev.expiresAt ? ` \xB7 valid until ${new Date(rev.expiresAt).toLocaleDateString()}` : null,
|
|
398
|
+
" \xB7 ",
|
|
399
|
+
/* @__PURE__ */ jsx("a", { href: q.pdfUrl, children: "Download PDF" })
|
|
400
|
+
] }) : null
|
|
401
|
+
] }),
|
|
402
|
+
q.error ? /* @__PURE__ */ jsx("p", { role: "alert", children: q.error.isSuperseded ? "This quote was updated \u2014 showing the latest version." : q.error.message }) : null,
|
|
403
|
+
rev ? /* @__PURE__ */ jsxs("table", { style: { width: "100%", borderCollapse: "collapse" }, children: [
|
|
404
|
+
/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
|
|
405
|
+
/* @__PURE__ */ jsx("th", { align: "left", children: "Item" }),
|
|
406
|
+
/* @__PURE__ */ jsx("th", { align: "right", children: "Unit price" }),
|
|
407
|
+
/* @__PURE__ */ jsx("th", { align: "right", children: "Qty" }),
|
|
408
|
+
/* @__PURE__ */ jsx("th", { align: "right", children: "Total" })
|
|
409
|
+
] }) }),
|
|
410
|
+
/* @__PURE__ */ jsx("tbody", { children: rev.items.map((item) => {
|
|
411
|
+
const state = q.selections[item.id] ?? { selected: true, quantity: item.quantity };
|
|
412
|
+
const included = item.optional ? state.selected : true;
|
|
413
|
+
return /* @__PURE__ */ jsxs("tr", { children: [
|
|
414
|
+
/* @__PURE__ */ jsxs("td", { children: [
|
|
415
|
+
item.optional ? /* @__PURE__ */ jsxs("label", { children: [
|
|
416
|
+
/* @__PURE__ */ jsx(
|
|
417
|
+
"input",
|
|
418
|
+
{
|
|
419
|
+
type: "checkbox",
|
|
420
|
+
checked: state.selected,
|
|
421
|
+
disabled: !canAct,
|
|
422
|
+
onChange: (e) => q.setSelected(item.id, e.target.checked)
|
|
423
|
+
}
|
|
424
|
+
),
|
|
425
|
+
" ",
|
|
426
|
+
item.name,
|
|
427
|
+
" ",
|
|
428
|
+
/* @__PURE__ */ jsx("span", { style: muted, children: "optional" })
|
|
429
|
+
] }) : item.name,
|
|
430
|
+
item.description ? /* @__PURE__ */ jsx("div", { style: muted, children: item.description }) : null
|
|
431
|
+
] }),
|
|
432
|
+
/* @__PURE__ */ jsx("td", { align: "right", children: formatMoney(item.unitPrice, currency) }),
|
|
433
|
+
/* @__PURE__ */ jsx("td", { align: "right", children: item.qtyEditable && canAct ? /* @__PURE__ */ jsx(
|
|
434
|
+
"input",
|
|
435
|
+
{
|
|
436
|
+
type: "number",
|
|
437
|
+
min: 1,
|
|
438
|
+
value: state.quantity,
|
|
439
|
+
disabled: !included,
|
|
440
|
+
"aria-label": `Quantity for ${item.name}`,
|
|
441
|
+
onChange: (e) => q.setQuantity(item.id, Number(e.target.value)),
|
|
442
|
+
style: { width: 64 }
|
|
443
|
+
}
|
|
444
|
+
) : state.quantity }),
|
|
445
|
+
/* @__PURE__ */ jsx("td", { align: "right", children: included ? formatMoney(
|
|
446
|
+
item.unitPrice * state.quantity + item.taxAmount - item.discountAmount,
|
|
447
|
+
currency
|
|
448
|
+
) : formatMoney(0, currency) })
|
|
449
|
+
] }, item.id);
|
|
450
|
+
}) })
|
|
451
|
+
] }) : null,
|
|
452
|
+
rev ? /* @__PURE__ */ jsxs("section", { children: [
|
|
453
|
+
/* @__PURE__ */ jsxs("div", { style: row, children: [
|
|
454
|
+
/* @__PURE__ */ jsx("strong", { children: "Total" }),
|
|
455
|
+
/* @__PURE__ */ jsx("strong", { children: formatMoney(q.total, currency) })
|
|
456
|
+
] }),
|
|
457
|
+
/* @__PURE__ */ jsx("p", { style: muted, children: termsLabel(rev.paymentTerms, rev.depositAmount, currency) }),
|
|
458
|
+
rev.leadTime ? /* @__PURE__ */ jsxs("p", { style: muted, children: [
|
|
459
|
+
"Lead time: ",
|
|
460
|
+
rev.leadTime
|
|
461
|
+
] }) : null,
|
|
462
|
+
rev.buyerNote ? /* @__PURE__ */ jsx("blockquote", { children: rev.buyerNote }) : null
|
|
463
|
+
] }) : null,
|
|
464
|
+
ordered ? /* @__PURE__ */ jsxs("p", { children: [
|
|
465
|
+
"Ordered",
|
|
466
|
+
quote.orderId ? ` \u2014 order ${quote.orderId}` : "",
|
|
467
|
+
". Thank you."
|
|
468
|
+
] }) : canAct ? /* @__PURE__ */ jsxs("section", { children: [
|
|
469
|
+
showAccept ? /* @__PURE__ */ jsxs(
|
|
470
|
+
"form",
|
|
471
|
+
{
|
|
472
|
+
onSubmit: (e) => {
|
|
473
|
+
e.preventDefault();
|
|
474
|
+
void handleAccept();
|
|
475
|
+
},
|
|
476
|
+
children: [
|
|
477
|
+
/* @__PURE__ */ jsxs("label", { children: [
|
|
478
|
+
"Your name",
|
|
479
|
+
/* @__PURE__ */ jsx("input", { value: name, onChange: (e) => setName(e.target.value), required: true })
|
|
480
|
+
] }),
|
|
481
|
+
/* @__PURE__ */ jsxs("label", { children: [
|
|
482
|
+
"Email",
|
|
483
|
+
/* @__PURE__ */ jsx("input", { type: "email", value: email, onChange: (e) => setEmail(e.target.value), required: true })
|
|
484
|
+
] }),
|
|
485
|
+
/* @__PURE__ */ jsx("button", { type: "submit", disabled: q.submitting || !name || !email, children: "Confirm & continue to payment" })
|
|
486
|
+
]
|
|
487
|
+
}
|
|
488
|
+
) : /* @__PURE__ */ jsx("button", { type: "button", onClick: () => setShowAccept(true), children: "Accept & checkout" }),
|
|
489
|
+
/* @__PURE__ */ jsx(
|
|
490
|
+
"button",
|
|
491
|
+
{
|
|
492
|
+
type: "button",
|
|
493
|
+
disabled: q.submitting,
|
|
494
|
+
onClick: () => void q.requestRevision("Please revise this quote.", name || void 0),
|
|
495
|
+
children: "Request changes"
|
|
496
|
+
}
|
|
497
|
+
),
|
|
498
|
+
/* @__PURE__ */ jsx("button", { type: "button", disabled: q.submitting, onClick: () => void q.decline(void 0, name || void 0), children: "Decline" })
|
|
499
|
+
] }) : null,
|
|
500
|
+
quote.revisionHistory.length > 0 ? /* @__PURE__ */ jsxs("details", { children: [
|
|
501
|
+
/* @__PURE__ */ jsxs("summary", { children: [
|
|
502
|
+
"Previous versions (",
|
|
503
|
+
quote.revisionHistory.length,
|
|
504
|
+
")"
|
|
505
|
+
] }),
|
|
506
|
+
/* @__PURE__ */ jsx("ul", { children: quote.revisionHistory.map((r) => /* @__PURE__ */ jsxs("li", { children: [
|
|
507
|
+
"Revision ",
|
|
508
|
+
r.revisionNumber,
|
|
509
|
+
" \u2014 ",
|
|
510
|
+
formatMoney(r.total, currency),
|
|
511
|
+
r.changeSummary ? ` \xB7 ${r.changeSummary}` : ""
|
|
512
|
+
] }, r.id)) })
|
|
513
|
+
] }) : null,
|
|
514
|
+
/* @__PURE__ */ jsxs("section", { children: [
|
|
515
|
+
/* @__PURE__ */ jsx("h2", { children: "Questions" }),
|
|
516
|
+
quote.comments.map((c) => /* @__PURE__ */ jsxs("p", { children: [
|
|
517
|
+
/* @__PURE__ */ jsxs("strong", { children: [
|
|
518
|
+
c.authorName ?? (c.authorType === "merchant" ? "Seller" : "You"),
|
|
519
|
+
":"
|
|
520
|
+
] }),
|
|
521
|
+
" ",
|
|
522
|
+
c.body
|
|
523
|
+
] }, c.id)),
|
|
524
|
+
/* @__PURE__ */ jsxs(
|
|
525
|
+
"form",
|
|
526
|
+
{
|
|
527
|
+
onSubmit: (e) => {
|
|
528
|
+
e.preventDefault();
|
|
529
|
+
const body = comment.trim();
|
|
530
|
+
if (!body) return;
|
|
531
|
+
setComment("");
|
|
532
|
+
void q.addComment(body, name || void 0);
|
|
533
|
+
},
|
|
534
|
+
children: [
|
|
535
|
+
/* @__PURE__ */ jsx(
|
|
536
|
+
"input",
|
|
537
|
+
{
|
|
538
|
+
value: comment,
|
|
539
|
+
onChange: (e) => setComment(e.target.value),
|
|
540
|
+
placeholder: "Ask a question\u2026",
|
|
541
|
+
"aria-label": "Ask a question"
|
|
542
|
+
}
|
|
543
|
+
),
|
|
544
|
+
/* @__PURE__ */ jsx("button", { type: "submit", disabled: q.submitting || !comment.trim(), children: "Send" })
|
|
545
|
+
]
|
|
546
|
+
}
|
|
547
|
+
)
|
|
548
|
+
] })
|
|
549
|
+
] });
|
|
550
|
+
}
|
|
551
|
+
function statusLabel(status) {
|
|
552
|
+
switch (status) {
|
|
553
|
+
case "in_progress":
|
|
554
|
+
return "We are preparing your quote";
|
|
555
|
+
case "ready":
|
|
556
|
+
return "Ready for your review";
|
|
557
|
+
case "being_revised":
|
|
558
|
+
return "We are revising this quote";
|
|
559
|
+
case "accepted":
|
|
560
|
+
return "Accepted \u2014 completing payment";
|
|
561
|
+
case "ordered":
|
|
562
|
+
return "Ordered";
|
|
563
|
+
case "declined":
|
|
564
|
+
return "Declined";
|
|
565
|
+
case "expired":
|
|
566
|
+
return "This quote has expired \u2014 request updated pricing";
|
|
567
|
+
default:
|
|
568
|
+
return status;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
function termsLabel(terms, depositAmount, currency) {
|
|
572
|
+
if (terms.mode === "net_terms") return `Payment terms: Net ${terms.netN}`;
|
|
573
|
+
if (terms.mode === "deposit_balance") {
|
|
574
|
+
const dep = depositAmount != null ? formatMoney(depositAmount, currency) : "a deposit";
|
|
575
|
+
return `${dep} due today, balance Net ${terms.balanceNetN}`;
|
|
576
|
+
}
|
|
577
|
+
return "Payment in full";
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// src/QuoteRequestForm.tsx
|
|
581
|
+
import { useState as useState3 } from "react";
|
|
582
|
+
import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
583
|
+
var honeypotStyle = {
|
|
584
|
+
position: "absolute",
|
|
585
|
+
width: 1,
|
|
586
|
+
height: 1,
|
|
587
|
+
padding: 0,
|
|
588
|
+
margin: -1,
|
|
589
|
+
overflow: "hidden",
|
|
590
|
+
clip: "rect(0 0 0 0)",
|
|
591
|
+
whiteSpace: "nowrap",
|
|
592
|
+
border: 0
|
|
593
|
+
};
|
|
594
|
+
function QuoteRequestForm({
|
|
595
|
+
formToken,
|
|
596
|
+
options,
|
|
597
|
+
turnstileToken,
|
|
598
|
+
onSubmitted,
|
|
599
|
+
loadingFallback,
|
|
600
|
+
className
|
|
601
|
+
}) {
|
|
602
|
+
const f = useQuoteRequestForm(formToken, options ?? {});
|
|
603
|
+
const [values, setValues] = useState3({
|
|
604
|
+
email: "",
|
|
605
|
+
name: "",
|
|
606
|
+
companyName: "",
|
|
607
|
+
phone: "",
|
|
608
|
+
buyerReference: "",
|
|
609
|
+
message: "",
|
|
610
|
+
website: ""
|
|
611
|
+
// honeypot
|
|
612
|
+
});
|
|
613
|
+
const set = (k) => (e) => setValues((prev) => ({ ...prev, [k]: e.target.value }));
|
|
614
|
+
if (f.loading) return /* @__PURE__ */ jsx2(Fragment2, { children: loadingFallback ?? /* @__PURE__ */ jsx2("p", { children: "Loading\u2026" }) });
|
|
615
|
+
if (!f.config) return /* @__PURE__ */ jsx2("p", { children: "This request form is not available." });
|
|
616
|
+
if (f.submitted) {
|
|
617
|
+
const redirect = f.config.redirectUrl;
|
|
618
|
+
if (redirect && /^https?:\/\//i.test(redirect) && typeof window !== "undefined") {
|
|
619
|
+
window.location.href = redirect;
|
|
620
|
+
}
|
|
621
|
+
return /* @__PURE__ */ jsx2("p", { children: f.config.successMessage ?? "Thanks \u2014 we will be in touch shortly." });
|
|
622
|
+
}
|
|
623
|
+
const fields = f.config.fields ?? {};
|
|
624
|
+
return /* @__PURE__ */ jsxs2(
|
|
625
|
+
"form",
|
|
626
|
+
{
|
|
627
|
+
className,
|
|
628
|
+
onSubmit: (e) => {
|
|
629
|
+
e.preventDefault();
|
|
630
|
+
void f.submit({
|
|
631
|
+
email: values.email,
|
|
632
|
+
message: values.message,
|
|
633
|
+
name: values.name || null,
|
|
634
|
+
companyName: fields.companyName ? values.companyName || null : null,
|
|
635
|
+
phone: fields.phone ? values.phone || null : null,
|
|
636
|
+
buyerReference: fields.buyerReference ? values.buyerReference || null : null,
|
|
637
|
+
website: values.website || null,
|
|
638
|
+
...turnstileToken ? { turnstileToken } : {}
|
|
639
|
+
}).then(() => onSubmitted?.()).catch(() => {
|
|
640
|
+
});
|
|
641
|
+
},
|
|
642
|
+
children: [
|
|
643
|
+
f.config.title ? /* @__PURE__ */ jsx2("h1", { children: f.config.title }) : null,
|
|
644
|
+
f.config.introText ? /* @__PURE__ */ jsx2("p", { children: f.config.introText }) : null,
|
|
645
|
+
f.error ? /* @__PURE__ */ jsx2("p", { role: "alert", children: f.error.message }) : null,
|
|
646
|
+
/* @__PURE__ */ jsxs2("label", { children: [
|
|
647
|
+
"Email",
|
|
648
|
+
/* @__PURE__ */ jsx2("input", { type: "email", value: values.email, onChange: set("email"), required: true })
|
|
649
|
+
] }),
|
|
650
|
+
/* @__PURE__ */ jsxs2("label", { children: [
|
|
651
|
+
"Name",
|
|
652
|
+
/* @__PURE__ */ jsx2("input", { value: values.name, onChange: set("name") })
|
|
653
|
+
] }),
|
|
654
|
+
fields.companyName ? /* @__PURE__ */ jsxs2("label", { children: [
|
|
655
|
+
"Company",
|
|
656
|
+
/* @__PURE__ */ jsx2("input", { value: values.companyName, onChange: set("companyName") })
|
|
657
|
+
] }) : null,
|
|
658
|
+
fields.phone ? /* @__PURE__ */ jsxs2("label", { children: [
|
|
659
|
+
"Phone",
|
|
660
|
+
/* @__PURE__ */ jsx2("input", { value: values.phone, onChange: set("phone") })
|
|
661
|
+
] }) : null,
|
|
662
|
+
fields.buyerReference ? /* @__PURE__ */ jsxs2("label", { children: [
|
|
663
|
+
"Your reference #",
|
|
664
|
+
/* @__PURE__ */ jsx2("input", { value: values.buyerReference, onChange: set("buyerReference") })
|
|
665
|
+
] }) : null,
|
|
666
|
+
/* @__PURE__ */ jsxs2("label", { children: [
|
|
667
|
+
"What do you need?",
|
|
668
|
+
/* @__PURE__ */ jsx2("textarea", { value: values.message, onChange: set("message"), required: true })
|
|
669
|
+
] }),
|
|
670
|
+
/* @__PURE__ */ jsx2("div", { style: honeypotStyle, "aria-hidden": "true", children: /* @__PURE__ */ jsxs2("label", { children: [
|
|
671
|
+
"Website",
|
|
672
|
+
/* @__PURE__ */ jsx2(
|
|
673
|
+
"input",
|
|
674
|
+
{
|
|
675
|
+
name: "website",
|
|
676
|
+
tabIndex: -1,
|
|
677
|
+
autoComplete: "off",
|
|
678
|
+
value: values.website,
|
|
679
|
+
onChange: set("website")
|
|
680
|
+
}
|
|
681
|
+
)
|
|
682
|
+
] }) }),
|
|
683
|
+
/* @__PURE__ */ jsx2("button", { type: "submit", disabled: f.submitting || !values.email || !values.message, children: f.submitting ? "Sending\u2026" : "Request a quote" })
|
|
684
|
+
]
|
|
685
|
+
}
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
export {
|
|
689
|
+
QuoteRequestForm,
|
|
690
|
+
QuoteView,
|
|
691
|
+
QuotesClient,
|
|
692
|
+
ThrottleQuotesError,
|
|
693
|
+
computeSelectionTotal,
|
|
694
|
+
createQuotesClient,
|
|
695
|
+
formatMoney,
|
|
696
|
+
scaleLineCharges,
|
|
697
|
+
useQuote,
|
|
698
|
+
useQuoteRequestForm
|
|
699
|
+
};
|
|
700
|
+
//# sourceMappingURL=index.js.map
|