@burdenoff/microfe-movethewheels 2026.920.1 → 2026.920.3

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.
@@ -14,15 +14,15 @@ var _ = /^[1-9]\d{5}$/, v = (e) => {
14
14
  return e.trim() !== "" && Number.isFinite(t) ? t : void 0;
15
15
  };
16
16
  function y() {
17
- let y = t(), b = p(), [x, S] = m(""), [C, w] = m(""), [T, E] = m(""), [D, O] = m(""), [k, A] = m(""), [j, M] = m(""), [N, P] = m(!1), [F, I] = m(!1), [L, R] = m(null), [z, B] = m(null), V = async (e) => {
18
- e.preventDefault(), R(null);
17
+ let y = t(), b = p(), [x, S] = m(""), [C, w] = m(""), [T, E] = m(""), [D, O] = m(""), [k, A] = m(""), [j, M] = m(""), [N, P] = m(!1), [F, I] = m(""), [L, R] = m(""), [z, B] = m(""), [V, H] = m(!1), [U, W] = m(null), [G, K] = m(null), q = async (e) => {
18
+ e.preventDefault(), W(null);
19
19
  let t = v(T);
20
20
  if (!_.test(x) || !_.test(C)) {
21
- R(y("movethewheels.shipping.form.postcodeError", "Enter valid 6-digit pickup and delivery postcodes"));
21
+ W(y("movethewheels.shipping.form.postcodeError", "Enter valid 6-digit pickup and delivery postcodes"));
22
22
  return;
23
23
  }
24
24
  if (t === void 0 || t <= 0) {
25
- R(y("movethewheels.shipping.form.weightError", "Enter the package weight in kg"));
25
+ W(y("movethewheels.shipping.form.weightError", "Enter the package weight in kg"));
26
26
  return;
27
27
  }
28
28
  let n = [
@@ -31,27 +31,41 @@ function y() {
31
31
  v(j)
32
32
  ], r = n.filter((e) => e !== void 0).length;
33
33
  if (r !== 0 && r !== 3) {
34
- R(y("movethewheels.shipping.form.dimensionsError", "Enter length, width and height together, or leave all three empty"));
34
+ W(y("movethewheels.shipping.form.dimensionsError", "Enter length, width and height together, or leave all three empty"));
35
35
  return;
36
36
  }
37
- let i = {
37
+ let i = v(F), a = v(L), o = v(z), s = i !== void 0 || a !== void 0;
38
+ if (s && (!i || i <= 0 || !a || a <= 0)) {
39
+ W(y("movethewheels.shipping.form.ownFleetError", "To compare with your own vehicle, enter both the distance and your cost per km"));
40
+ return;
41
+ }
42
+ if (o !== void 0 && o < 0) {
43
+ W(y("movethewheels.shipping.form.fixedCostError", "Fixed cost cannot be negative"));
44
+ return;
45
+ }
46
+ let c = {
38
47
  pickupPostcode: x,
39
48
  deliveryPostcode: C,
40
49
  weightKg: t,
41
50
  cod: N,
51
+ ...s && i && a ? { ownFleet: {
52
+ distanceKm: i,
53
+ costPerKm: a,
54
+ ...o ? { fixedCost: o } : {}
55
+ } } : {},
42
56
  ...r === 3 ? {
43
57
  lengthCm: n[0],
44
58
  widthCm: n[1],
45
59
  heightCm: n[2]
46
60
  } : {}
47
61
  };
48
- I(!0);
62
+ H(!0);
49
63
  try {
50
- B(await b(i));
64
+ K(await b(c));
51
65
  } catch (e) {
52
- B(null), R(e instanceof Error ? e.message : y("movethewheels.shipping.form.failed", "Could not fetch rates"));
66
+ K(null), W(e instanceof Error ? e.message : y("movethewheels.shipping.form.failed", "Could not fetch rates"));
53
67
  } finally {
54
- I(!1);
68
+ H(!1);
55
69
  }
56
70
  };
57
71
  return /* @__PURE__ */ h(u, {
@@ -61,7 +75,7 @@ function y() {
61
75
  children: /* @__PURE__ */ g("div", {
62
76
  className: "space-y-6",
63
77
  children: [/* @__PURE__ */ g(r, { children: [/* @__PURE__ */ h(a, { children: /* @__PURE__ */ h(o, { children: y("movethewheels.shipping.form.title", "Shipment details") }) }), /* @__PURE__ */ h(i, { children: /* @__PURE__ */ g("form", {
64
- onSubmit: (e) => void V(e),
78
+ onSubmit: (e) => void q(e),
65
79
  className: "space-y-4",
66
80
  children: [
67
81
  /* @__PURE__ */ g("div", {
@@ -148,6 +162,60 @@ function y() {
148
162
  })
149
163
  ]
150
164
  }),
165
+ /* @__PURE__ */ g("fieldset", {
166
+ className: "space-y-3 rounded-lg border p-4",
167
+ children: [
168
+ /* @__PURE__ */ h("legend", {
169
+ className: "px-1 text-sm font-medium",
170
+ children: y("movethewheels.shipping.form.ownFleetTitle", "Compare with your own vehicle (optional)")
171
+ }),
172
+ /* @__PURE__ */ h("p", {
173
+ className: "text-sm text-text-secondary",
174
+ children: y("movethewheels.shipping.form.ownFleetHelp", "Enter the road distance and what it costs you to run a vehicle per km, and we will show it next to the courier prices.")
175
+ }),
176
+ /* @__PURE__ */ g("div", {
177
+ className: "grid gap-3 sm:grid-cols-3",
178
+ children: [
179
+ /* @__PURE__ */ g("div", {
180
+ className: "space-y-1",
181
+ children: [/* @__PURE__ */ h(c, {
182
+ htmlFor: "rate-distance",
183
+ children: y("movethewheels.shipping.form.distance", "Distance (km)")
184
+ }), /* @__PURE__ */ h(s, {
185
+ id: "rate-distance",
186
+ inputMode: "decimal",
187
+ value: F,
188
+ onChange: (e) => I(e.target.value)
189
+ })]
190
+ }),
191
+ /* @__PURE__ */ g("div", {
192
+ className: "space-y-1",
193
+ children: [/* @__PURE__ */ h(c, {
194
+ htmlFor: "rate-cost-per-km",
195
+ children: y("movethewheels.shipping.form.costPerKm", "Your cost per km (₹)")
196
+ }), /* @__PURE__ */ h(s, {
197
+ id: "rate-cost-per-km",
198
+ inputMode: "decimal",
199
+ value: L,
200
+ onChange: (e) => R(e.target.value)
201
+ })]
202
+ }),
203
+ /* @__PURE__ */ g("div", {
204
+ className: "space-y-1",
205
+ children: [/* @__PURE__ */ h(c, {
206
+ htmlFor: "rate-fixed-cost",
207
+ children: y("movethewheels.shipping.form.fixedCost", "Fixed cost per trip (₹, optional)")
208
+ }), /* @__PURE__ */ h(s, {
209
+ id: "rate-fixed-cost",
210
+ inputMode: "decimal",
211
+ value: z,
212
+ onChange: (e) => B(e.target.value)
213
+ })]
214
+ })
215
+ ]
216
+ })
217
+ ]
218
+ }),
151
219
  /* @__PURE__ */ g("div", {
152
220
  className: "flex items-center gap-2",
153
221
  children: [/* @__PURE__ */ h(l, {
@@ -159,15 +227,15 @@ function y() {
159
227
  children: y("movethewheels.shipping.form.cod", "Cash on delivery")
160
228
  })]
161
229
  }),
162
- /* @__PURE__ */ h(d, { message: L }),
230
+ /* @__PURE__ */ h(d, { message: U }),
163
231
  /* @__PURE__ */ h(n, {
164
232
  type: "submit",
165
- disabled: F,
166
- children: F ? y("movethewheels.shipping.form.comparing", "Comparing…") : y("movethewheels.shipping.form.compare", "Compare rates")
233
+ disabled: V,
234
+ children: V ? y("movethewheels.shipping.form.comparing", "Comparing…") : y("movethewheels.shipping.form.compare", "Compare rates")
167
235
  })
168
236
  ]
169
- }) })] }), z && /* @__PURE__ */ h(f, {
170
- result: z,
237
+ }) })] }), G && /* @__PURE__ */ h(f, {
238
+ result: G,
171
239
  t: y
172
240
  })]
173
241
  })
@@ -2,5 +2,26 @@
2
2
  function e(e, t) {
3
3
  return (e.endsWith("/") ? e.slice(0, -1) : e) + (t.startsWith("/") ? t : `/${t}`);
4
4
  }
5
+ function t(e, t) {
6
+ if (!t || Object.keys(t).length === 0) return e;
7
+ let n = new URLSearchParams();
8
+ for (let [e, r] of Object.entries(t)) r != null && r !== "" && n.set(e, String(r));
9
+ let r = n.toString();
10
+ return r ? `${e}?${r}` : e;
11
+ }
12
+ var n = [/^orders\/new$/, /^orders\/[^/?#]+\/edit$/];
13
+ function r(e) {
14
+ if (!e || e.length > 200 || e.includes("://") || e.startsWith("/")) return null;
15
+ let [t] = e.split(/[?#]/, 1);
16
+ return n.some((e) => e.test(t)) ? t : null;
17
+ }
18
+ function i(e, n) {
19
+ let i = r(e);
20
+ return i ? t(i, { customerId: n }) : e;
21
+ }
22
+ function a(e) {
23
+ let n = r(e);
24
+ return n ? t("customers/new", { returnTo: n }) : "customers/new";
25
+ }
5
26
  //#endregion
6
- export { e as joinPath };
27
+ export { a as buildCustomerCreatePath, i as buildOrderCustomerPath, r as getSafeCustomerReturnPath, e as joinPath };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@burdenoff/microfe-movethewheels",
3
- "version": "2026.920.1",
3
+ "version": "2026.920.3",
4
4
  "description": "Burdenoff MoveTheWheels Microfrontend - Logistics & Supply Chain Management Platform",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,307 @@
1
+ import { useState, type ChangeEvent, type FormEvent } from 'react';
2
+ import { Plus, RefreshCw, UserPlus } from 'lucide-react';
3
+ import {
4
+ Button,
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ Input,
12
+ Label,
13
+ Select,
14
+ SelectContent,
15
+ SelectItem,
16
+ SelectTrigger,
17
+ SelectValue,
18
+ } from './ui';
19
+ import type { Customer } from '../types';
20
+ import { FormError } from './FormError';
21
+
22
+ interface CustomerPickerProps {
23
+ customers: Customer[];
24
+ value: string;
25
+ onValueChange: (value: string) => void;
26
+ onCreated: (customer: Customer) => void;
27
+ createCustomer: (customer: Partial<Customer>) => Promise<Customer>;
28
+ isLoading: boolean;
29
+ error: Error | null;
30
+ onRetry: () => void;
31
+ onOpenCustomers?: () => void;
32
+ selectedCustomer?: Customer | null;
33
+ disabled?: boolean;
34
+ }
35
+
36
+ interface CustomerFormState {
37
+ name: string;
38
+ type: Customer['type'];
39
+ email: string;
40
+ phone: string;
41
+ company: string;
42
+ }
43
+
44
+ const EMPTY_FORM: CustomerFormState = {
45
+ name: '',
46
+ type: 'b2c',
47
+ email: '',
48
+ phone: '',
49
+ company: '',
50
+ };
51
+
52
+ function mergeCustomers(customers: Customer[], selectedCustomer?: Customer | null): Customer[] {
53
+ if (!selectedCustomer || customers.some((customer) => customer.id === selectedCustomer.id)) {
54
+ return customers;
55
+ }
56
+ return [selectedCustomer, ...customers];
57
+ }
58
+
59
+ export function CustomerPicker({
60
+ customers,
61
+ value,
62
+ onValueChange,
63
+ onCreated,
64
+ createCustomer,
65
+ isLoading,
66
+ error,
67
+ onRetry,
68
+ onOpenCustomers,
69
+ selectedCustomer,
70
+ disabled = false,
71
+ }: CustomerPickerProps) {
72
+ const [isCreateOpen, setIsCreateOpen] = useState(false);
73
+ const [form, setForm] = useState<CustomerFormState>(EMPTY_FORM);
74
+ const [isCreating, setIsCreating] = useState(false);
75
+ const [createError, setCreateError] = useState<string | null>(null);
76
+ const options = mergeCustomers(customers, selectedCustomer);
77
+
78
+ const openCreateDialog = () => {
79
+ setCreateError(null);
80
+ setForm(EMPTY_FORM);
81
+ setIsCreateOpen(true);
82
+ };
83
+
84
+ const closeCreateDialog = () => {
85
+ if (isCreating) return;
86
+ setIsCreateOpen(false);
87
+ };
88
+
89
+ const handleCreate = async (event: FormEvent<HTMLFormElement>) => {
90
+ event.preventDefault();
91
+ event.stopPropagation();
92
+ const name = form.name.trim();
93
+ if (!name) {
94
+ setCreateError('Customer name is required.');
95
+ return;
96
+ }
97
+
98
+ setIsCreating(true);
99
+ setCreateError(null);
100
+ try {
101
+ const created = await createCustomer({
102
+ name,
103
+ type: form.type,
104
+ email: form.email.trim() || undefined,
105
+ phone: form.phone.trim() || undefined,
106
+ company: form.company.trim() || undefined,
107
+ });
108
+ onCreated(created);
109
+ setIsCreateOpen(false);
110
+ setForm(EMPTY_FORM);
111
+ } catch (creationError) {
112
+ setCreateError(
113
+ creationError instanceof Error
114
+ ? creationError.message
115
+ : 'Could not create the customer. Please try again.'
116
+ );
117
+ } finally {
118
+ setIsCreating(false);
119
+ }
120
+ };
121
+
122
+ return (
123
+ <div className="space-y-2">
124
+ <div className="flex items-center justify-between gap-2">
125
+ <Label htmlFor="customer">Customer</Label>
126
+ <Button
127
+ type="button"
128
+ variant="ghost"
129
+ size="sm"
130
+ onClick={openCreateDialog}
131
+ disabled={disabled || isLoading}
132
+ >
133
+ <Plus size={14} className="mr-1" />
134
+ Add customer
135
+ </Button>
136
+ </div>
137
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-start">
138
+ <Select
139
+ value={value}
140
+ onValueChange={onValueChange}
141
+ disabled={disabled || isLoading || Boolean(error)}
142
+ >
143
+ <SelectTrigger id="customer" className="flex-1">
144
+ <SelectValue placeholder={isLoading ? 'Loading customers...' : 'Select customer'} />
145
+ </SelectTrigger>
146
+ <SelectContent>
147
+ {options.map((customer) => (
148
+ <SelectItem key={customer.id} value={customer.id}>
149
+ {customer.name} {customer.email ? `(${customer.email})` : ''}
150
+ </SelectItem>
151
+ ))}
152
+ </SelectContent>
153
+ </Select>
154
+ {error && (
155
+ <Button type="button" variant="outline" size="sm" onClick={onRetry}>
156
+ <RefreshCw size={14} className="mr-1" />
157
+ Retry
158
+ </Button>
159
+ )}
160
+ </div>
161
+ {error ? (
162
+ <div className="flex flex-wrap items-center gap-2">
163
+ <p role="alert" className="text-sm text-status-error-text">
164
+ Customers could not be loaded. Retry or add a customer to continue.
165
+ </p>
166
+ {onOpenCustomers && (
167
+ <Button type="button" variant="outline" size="sm" onClick={onOpenCustomers}>
168
+ Open customer page
169
+ </Button>
170
+ )}
171
+ </div>
172
+ ) : !isLoading && options.length === 0 ? (
173
+ <div className="rounded-lg border border-border-seam bg-bg-sunken/40 p-3">
174
+ <div className="flex items-start gap-2">
175
+ <UserPlus size={16} className="mt-0.5 shrink-0 text-text-secondary" />
176
+ <div>
177
+ <p className="text-sm font-medium text-text-primary">Add a customer to continue</p>
178
+ <p className="text-sm text-text-secondary">
179
+ The customer will be selected automatically and your order details will stay here.
180
+ </p>
181
+ </div>
182
+ </div>
183
+ <Button
184
+ type="button"
185
+ size="sm"
186
+ className="mt-3"
187
+ onClick={openCreateDialog}
188
+ disabled={disabled}
189
+ >
190
+ <Plus size={14} className="mr-1.5" />
191
+ Create customer
192
+ </Button>
193
+ </div>
194
+ ) : null}
195
+
196
+ <Dialog
197
+ open={isCreateOpen}
198
+ onOpenChange={(open: boolean) => (open ? setIsCreateOpen(true) : closeCreateDialog())}
199
+ >
200
+ <DialogContent>
201
+ <DialogHeader>
202
+ <DialogTitle>Create customer</DialogTitle>
203
+ <DialogDescription>
204
+ Add the customer without leaving this order. They will be selected automatically.
205
+ </DialogDescription>
206
+ </DialogHeader>
207
+ <form onSubmit={handleCreate} className="space-y-4">
208
+ <FormError message={createError} />
209
+ <div className="space-y-2">
210
+ <Label htmlFor="quick-customer-name">Name</Label>
211
+ <Input
212
+ id="quick-customer-name"
213
+ value={form.name}
214
+ onChange={(event: ChangeEvent<HTMLInputElement>) =>
215
+ setForm((previous) => ({ ...previous, name: event.target.value }))
216
+ }
217
+ placeholder="Acme Corp or Jane Doe"
218
+ autoFocus
219
+ required
220
+ />
221
+ </div>
222
+ <div className="space-y-2">
223
+ <Label htmlFor="quick-customer-type">Customer type</Label>
224
+ <Select
225
+ value={form.type}
226
+ onValueChange={(value: string) =>
227
+ setForm((previous) => ({ ...previous, type: value as Customer['type'] }))
228
+ }
229
+ >
230
+ <SelectTrigger id="quick-customer-type">
231
+ <SelectValue />
232
+ </SelectTrigger>
233
+ <SelectContent>
234
+ <SelectItem value="b2c">B2C (Individual)</SelectItem>
235
+ <SelectItem value="b2b">B2B (Business)</SelectItem>
236
+ </SelectContent>
237
+ </Select>
238
+ </div>
239
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
240
+ <div className="space-y-2">
241
+ <Label htmlFor="quick-customer-email">Email</Label>
242
+ <Input
243
+ id="quick-customer-email"
244
+ type="email"
245
+ value={form.email}
246
+ onChange={(event: ChangeEvent<HTMLInputElement>) =>
247
+ setForm((previous) => ({ ...previous, email: event.target.value }))
248
+ }
249
+ placeholder="customer@example.com"
250
+ />
251
+ </div>
252
+ <div className="space-y-2">
253
+ <Label htmlFor="quick-customer-phone">Phone</Label>
254
+ <Input
255
+ id="quick-customer-phone"
256
+ value={form.phone}
257
+ onChange={(event: ChangeEvent<HTMLInputElement>) =>
258
+ setForm((previous) => ({ ...previous, phone: event.target.value }))
259
+ }
260
+ placeholder="+1-555-0100"
261
+ />
262
+ </div>
263
+ </div>
264
+ <div className="space-y-2">
265
+ <Label htmlFor="quick-customer-company">Company</Label>
266
+ <Input
267
+ id="quick-customer-company"
268
+ value={form.company}
269
+ onChange={(event: ChangeEvent<HTMLInputElement>) =>
270
+ setForm((previous) => ({ ...previous, company: event.target.value }))
271
+ }
272
+ placeholder="Company name (optional)"
273
+ />
274
+ </div>
275
+ <DialogFooter>
276
+ {onOpenCustomers && (
277
+ <Button
278
+ type="button"
279
+ variant="outline"
280
+ onClick={() => {
281
+ closeCreateDialog();
282
+ onOpenCustomers();
283
+ }}
284
+ disabled={isCreating}
285
+ >
286
+ Use customer page
287
+ </Button>
288
+ )}
289
+ <Button
290
+ type="button"
291
+ variant="outline"
292
+ onClick={closeCreateDialog}
293
+ disabled={isCreating}
294
+ >
295
+ Cancel
296
+ </Button>
297
+ <Button type="submit" disabled={isCreating}>
298
+ <Plus size={16} />
299
+ {isCreating ? 'Creating...' : 'Create and select'}
300
+ </Button>
301
+ </DialogFooter>
302
+ </form>
303
+ </DialogContent>
304
+ </Dialog>
305
+ </div>
306
+ );
307
+ }
@@ -9,7 +9,7 @@
9
9
  * number people compare on — off-screen, so each courier is a card instead.
10
10
  */
11
11
 
12
- import { AlertTriangle } from 'lucide-react';
12
+ import { AlertTriangle, Truck } from 'lucide-react';
13
13
 
14
14
  import { StatusBadge } from './StatusBadge';
15
15
  import {
@@ -25,7 +25,11 @@ import {
25
25
  TableRow,
26
26
  } from './ui';
27
27
 
28
- import type { ShippingQuote, ShippingQuoteResult } from '../hooks/useShippingCarriers';
28
+ import type {
29
+ OwnFleetEstimate,
30
+ ShippingQuote,
31
+ ShippingQuoteResult,
32
+ } from '../hooks/useShippingCarriers';
29
33
  import { formatCurrency } from '../utils/formatters';
30
34
 
31
35
  interface ShippingQuoteResultsProps {
@@ -83,6 +87,60 @@ function QuoteBadges({ quote, t }: { quote: ShippingQuote; t: ShippingQuoteResul
83
87
  );
84
88
  }
85
89
 
90
+ function OwnFleetCard({
91
+ estimate,
92
+ t,
93
+ }: {
94
+ estimate: OwnFleetEstimate;
95
+ t: ShippingQuoteResultsProps['t'];
96
+ }) {
97
+ const savings = estimate.savingsVsCheapestCourier;
98
+ return (
99
+ <Card>
100
+ <CardHeader>
101
+ <CardTitle className="flex items-center gap-2">
102
+ <Truck size={18} aria-hidden="true" />
103
+ {t('movethewheels.shipping.ownFleet.title', 'Your own vehicle')}
104
+ <StatusBadge
105
+ status={t('movethewheels.shipping.ownFleet.estimate', 'Your estimate')}
106
+ customColors={INFO}
107
+ size="sm"
108
+ showDot={false}
109
+ />
110
+ </CardTitle>
111
+ </CardHeader>
112
+ <CardContent className="space-y-2">
113
+ <p className="text-2xl font-semibold">{formatCurrency(estimate.cost, estimate.currency)}</p>
114
+ <p className="text-sm text-text-secondary">
115
+ {estimate.distanceKm} km × {formatCurrency(estimate.costPerKm, estimate.currency)}/km
116
+ {estimate.fixedCost > 0 && ` + ${formatCurrency(estimate.fixedCost, estimate.currency)}`}
117
+ </p>
118
+ {savings !== null && (
119
+ <p
120
+ className={`text-sm font-medium ${
121
+ estimate.isCheaperThanCouriers
122
+ ? 'text-status-success-text'
123
+ : 'text-status-warning-text'
124
+ }`}
125
+ >
126
+ {estimate.isCheaperThanCouriers
127
+ ? `${t('movethewheels.shipping.ownFleet.cheaper', 'Your own vehicle is cheaper than the cheapest courier by')} ${formatCurrency(savings, estimate.currency)}`
128
+ : savings === 0
129
+ ? t('movethewheels.shipping.ownFleet.same', 'Same cost as the cheapest courier')
130
+ : `${t('movethewheels.shipping.ownFleet.dearer', 'The cheapest courier is cheaper by')} ${formatCurrency(-savings, estimate.currency)}`}
131
+ </p>
132
+ )}
133
+ <p className="text-xs text-text-secondary">
134
+ {t(
135
+ 'movethewheels.shipping.ownFleet.note',
136
+ 'Based only on the distance and cost per km you entered — not a quote.'
137
+ )}
138
+ </p>
139
+ </CardContent>
140
+ </Card>
141
+ );
142
+ }
143
+
86
144
  export function ShippingQuoteResults({ result, t }: ShippingQuoteResultsProps) {
87
145
  const failed = result.providers.filter((provider) => !provider.ok);
88
146
 
@@ -99,6 +157,8 @@ export function ShippingQuoteResults({ result, t }: ShippingQuoteResultsProps) {
99
157
  </div>
100
158
  ))}
101
159
 
160
+ {result.ownFleet && <OwnFleetCard estimate={result.ownFleet} t={t} />}
161
+
102
162
  <Card>
103
163
  <CardHeader>
104
164
  <CardTitle>
@@ -13,7 +13,14 @@ export const OSM_ATTRIBUTION =
13
13
  '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
14
14
 
15
15
  export type MapMarkerStatus =
16
- 'active' | 'in_transit' | 'idle' | 'delayed' | 'delivered' | 'origin' | 'destination' | 'default';
16
+ | 'active'
17
+ | 'in_transit'
18
+ | 'idle'
19
+ | 'delayed'
20
+ | 'delivered'
21
+ | 'origin'
22
+ | 'destination'
23
+ | 'default';
17
24
 
18
25
  /**
19
26
  * A coordinate is "real" only when it is finite and not the null-island (0,0)
@@ -12,7 +12,12 @@ import { executeGraphQL } from '../utils/graphqlClient';
12
12
 
13
13
  export type TransportMode = 'ROAD' | 'AIR' | 'OCEAN' | 'RAIL';
14
14
  export type ShipmentLegStatus =
15
- 'PLANNED' | 'BOOKED' | 'IN_TRANSIT' | 'ARRIVED' | 'DELIVERED' | 'CANCELLED';
15
+ | 'PLANNED'
16
+ | 'BOOKED'
17
+ | 'IN_TRANSIT'
18
+ | 'ARRIVED'
19
+ | 'DELIVERED'
20
+ | 'CANCELLED';
16
21
  export type OceanContainerType = 'LCL' | 'FCL_20FT' | 'FCL_40FT' | 'FCL_40FT_HC';
17
22
 
18
23
  export interface FreightRateBreakdownLine {
@@ -11,6 +11,23 @@ import { executeGraphQL } from '../utils/graphqlClient';
11
11
  export type CarrierProvider = 'SHIPROCKET';
12
12
  export type CarrierServiceMode = 'SURFACE' | 'AIR';
13
13
 
14
+ export interface OwnFleetInput {
15
+ distanceKm: number;
16
+ costPerKm: number;
17
+ fixedCost?: number;
18
+ }
19
+
20
+ export interface OwnFleetEstimate {
21
+ distanceKm: number;
22
+ costPerKm: number;
23
+ fixedCost: number;
24
+ cost: number;
25
+ currency: string;
26
+ /** Cheapest courier price minus this estimate; positive = your own vehicle is cheaper. */
27
+ savingsVsCheapestCourier: number | null;
28
+ isCheaperThanCouriers: boolean;
29
+ }
30
+
14
31
  export interface ShippingQuoteInput {
15
32
  pickupPostcode: string;
16
33
  deliveryPostcode: string;
@@ -19,6 +36,7 @@ export interface ShippingQuoteInput {
19
36
  widthCm?: number;
20
37
  heightCm?: number;
21
38
  cod?: boolean;
39
+ ownFleet?: OwnFleetInput;
22
40
  }
23
41
 
24
42
  export interface ShippingQuote {
@@ -45,6 +63,7 @@ export interface CarrierOutcome {
45
63
 
46
64
  export interface ShippingQuoteResult {
47
65
  chargeableWeightKg: number;
66
+ ownFleet: OwnFleetEstimate | null;
48
67
  quotes: ShippingQuote[];
49
68
  providers: CarrierOutcome[];
50
69
  }
@@ -53,6 +72,15 @@ const SHIPPING_QUOTES_QUERY = `
53
72
  query ShippingQuotes($input: ShippingQuoteInput!) {
54
73
  shippingQuotes(input: $input) {
55
74
  chargeableWeightKg
75
+ ownFleet {
76
+ distanceKm
77
+ costPerKm
78
+ fixedCost
79
+ cost
80
+ currency
81
+ savingsVsCheapestCourier
82
+ isCheaperThanCouriers
83
+ }
56
84
  quotes {
57
85
  provider
58
86
  courierId