@burdenoff/microfe-movethewheels 2026.918.1 → 2026.920.2
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/dist/MoveTheWheelsRoutes.js +7 -3
- package/dist/components/ShippingQuoteResults.js +195 -0
- package/dist/hooks/useShippingCarriers.js +29 -0
- package/dist/movethewheels/components/ShippingQuoteResults.d.ts +8 -0
- package/dist/movethewheels/hooks/useShippingCarriers.d.ts +60 -0
- package/dist/movethewheels/pages/ShippingRatesPage.d.ts +9 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/scale.js +26 -0
- package/dist/pages/ShippingRatesPage.js +245 -0
- package/package.json +1 -1
- package/src/movethewheels/MoveTheWheelsRoutes.tsx +2 -0
- package/src/movethewheels/components/ShippingQuoteResults.tsx +259 -0
- package/src/movethewheels/components/maps/leaflet-setup.ts +8 -1
- package/src/movethewheels/hooks/useMultiModalShipments.ts +6 -1
- package/src/movethewheels/hooks/useShippingCarriers.ts +138 -0
- package/src/movethewheels/pages/ShippingRatesPage.tsx +301 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ShippingQuoteResults
|
|
3
|
+
*
|
|
4
|
+
* Every courier option from every carrier for one pincode pair, cheapest first,
|
|
5
|
+
* with the cheapest and fastest called out. A carrier that failed is listed
|
|
6
|
+
* with its reason instead of silently missing from the comparison.
|
|
7
|
+
*
|
|
8
|
+
* Wide screens get a table. On a phone a table pushes the price — the one
|
|
9
|
+
* number people compare on — off-screen, so each courier is a card instead.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { AlertTriangle, Truck } from 'lucide-react';
|
|
13
|
+
|
|
14
|
+
import { StatusBadge } from './StatusBadge';
|
|
15
|
+
import {
|
|
16
|
+
Card,
|
|
17
|
+
CardContent,
|
|
18
|
+
CardHeader,
|
|
19
|
+
CardTitle,
|
|
20
|
+
Table,
|
|
21
|
+
TableBody,
|
|
22
|
+
TableCell,
|
|
23
|
+
TableHead,
|
|
24
|
+
TableHeader,
|
|
25
|
+
TableRow,
|
|
26
|
+
} from './ui';
|
|
27
|
+
|
|
28
|
+
import type {
|
|
29
|
+
OwnFleetEstimate,
|
|
30
|
+
ShippingQuote,
|
|
31
|
+
ShippingQuoteResult,
|
|
32
|
+
} from '../hooks/useShippingCarriers';
|
|
33
|
+
import { formatCurrency } from '../utils/formatters';
|
|
34
|
+
|
|
35
|
+
interface ShippingQuoteResultsProps {
|
|
36
|
+
result: ShippingQuoteResult;
|
|
37
|
+
t: (key: string, fallback: string) => string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const HIGHLIGHT = { bg: 'bg-status-success-bg-subtle', text: 'text-status-success-text' };
|
|
41
|
+
const INFO = { bg: 'bg-status-info-bg-subtle', text: 'text-status-info-text' };
|
|
42
|
+
|
|
43
|
+
function transit(quote: ShippingQuote, t: ShippingQuoteResultsProps['t']): string {
|
|
44
|
+
if (quote.estimatedDeliveryDays === null) return quote.estimatedDeliveryDate ?? '—';
|
|
45
|
+
const unit =
|
|
46
|
+
quote.estimatedDeliveryDays === 1
|
|
47
|
+
? t('movethewheels.shipping.results.day', 'day')
|
|
48
|
+
: t('movethewheels.shipping.results.days', 'days');
|
|
49
|
+
const days = `${quote.estimatedDeliveryDays} ${unit}`;
|
|
50
|
+
return quote.estimatedDeliveryDate ? `${days} · ${quote.estimatedDeliveryDate}` : days;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function modeLabel(quote: ShippingQuote, t: ShippingQuoteResultsProps['t']): string {
|
|
54
|
+
if (quote.mode === 'AIR') return t('movethewheels.shipping.results.air', 'Air');
|
|
55
|
+
if (quote.mode === 'SURFACE') return t('movethewheels.shipping.results.surface', 'Surface');
|
|
56
|
+
return '—';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function QuoteBadges({ quote, t }: { quote: ShippingQuote; t: ShippingQuoteResultsProps['t'] }) {
|
|
60
|
+
return (
|
|
61
|
+
<>
|
|
62
|
+
{quote.isCheapest && (
|
|
63
|
+
<StatusBadge
|
|
64
|
+
status={t('movethewheels.shipping.results.cheapest', 'Cheapest')}
|
|
65
|
+
customColors={HIGHLIGHT}
|
|
66
|
+
size="sm"
|
|
67
|
+
showDot={false}
|
|
68
|
+
/>
|
|
69
|
+
)}
|
|
70
|
+
{quote.isFastest && (
|
|
71
|
+
<StatusBadge
|
|
72
|
+
status={t('movethewheels.shipping.results.fastest', 'Fastest')}
|
|
73
|
+
customColors={INFO}
|
|
74
|
+
size="sm"
|
|
75
|
+
showDot={false}
|
|
76
|
+
/>
|
|
77
|
+
)}
|
|
78
|
+
{quote.isRecommended && (
|
|
79
|
+
<StatusBadge
|
|
80
|
+
status={t('movethewheels.shipping.results.recommended', 'Recommended')}
|
|
81
|
+
customColors={INFO}
|
|
82
|
+
size="sm"
|
|
83
|
+
showDot={false}
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
</>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
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
|
+
|
|
144
|
+
export function ShippingQuoteResults({ result, t }: ShippingQuoteResultsProps) {
|
|
145
|
+
const failed = result.providers.filter((provider) => !provider.ok);
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div className="space-y-4">
|
|
149
|
+
{failed.map((provider) => (
|
|
150
|
+
<div
|
|
151
|
+
key={provider.provider}
|
|
152
|
+
role="alert"
|
|
153
|
+
className="flex items-start gap-2 rounded-lg border border-status-warning-border bg-status-warning-bg-subtle px-4 py-3 text-sm text-status-warning-text"
|
|
154
|
+
>
|
|
155
|
+
<AlertTriangle size={18} className="mt-0.5 shrink-0" aria-hidden="true" />
|
|
156
|
+
<span>{provider.errorMessage}</span>
|
|
157
|
+
</div>
|
|
158
|
+
))}
|
|
159
|
+
|
|
160
|
+
{result.ownFleet && <OwnFleetCard estimate={result.ownFleet} t={t} />}
|
|
161
|
+
|
|
162
|
+
<Card>
|
|
163
|
+
<CardHeader>
|
|
164
|
+
<CardTitle>
|
|
165
|
+
{t('movethewheels.shipping.results.title', 'Courier options')} ({result.quotes.length})
|
|
166
|
+
</CardTitle>
|
|
167
|
+
<p className="text-sm text-text-secondary">
|
|
168
|
+
{t('movethewheels.shipping.results.chargeableWeight', 'Charged on')}{' '}
|
|
169
|
+
{result.chargeableWeightKg} kg
|
|
170
|
+
</p>
|
|
171
|
+
</CardHeader>
|
|
172
|
+
<CardContent className="p-0">
|
|
173
|
+
{result.quotes.length === 0 ? (
|
|
174
|
+
<p className="px-6 pb-6 text-sm text-text-secondary">
|
|
175
|
+
{failed.length > 0
|
|
176
|
+
? t(
|
|
177
|
+
'movethewheels.shipping.results.allFailed',
|
|
178
|
+
'No rates could be fetched — see the message above.'
|
|
179
|
+
)
|
|
180
|
+
: t(
|
|
181
|
+
'movethewheels.shipping.results.noServiceable',
|
|
182
|
+
'No courier serves this pincode pair.'
|
|
183
|
+
)}
|
|
184
|
+
</p>
|
|
185
|
+
) : (
|
|
186
|
+
<>
|
|
187
|
+
<ul
|
|
188
|
+
className="divide-y md:hidden"
|
|
189
|
+
aria-label={t('movethewheels.shipping.results.title', 'Courier options')}
|
|
190
|
+
>
|
|
191
|
+
{result.quotes.map((quote) => (
|
|
192
|
+
<li key={`${quote.provider}:${quote.courierId}`} className="space-y-2 px-4 py-4">
|
|
193
|
+
<div className="flex items-start justify-between gap-3">
|
|
194
|
+
<span className="min-w-0 font-medium">{quote.courierName}</span>
|
|
195
|
+
<span className="shrink-0 text-lg font-semibold">
|
|
196
|
+
{formatCurrency(quote.price, quote.currency)}
|
|
197
|
+
</span>
|
|
198
|
+
</div>
|
|
199
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
200
|
+
<QuoteBadges quote={quote} t={t} />
|
|
201
|
+
</div>
|
|
202
|
+
<p className="text-sm text-text-secondary">
|
|
203
|
+
{[
|
|
204
|
+
modeLabel(quote, t),
|
|
205
|
+
transit(quote, t),
|
|
206
|
+
quote.rating === null ? null : `${quote.rating.toFixed(1)} ★`,
|
|
207
|
+
]
|
|
208
|
+
.filter((part) => part !== '—' && part !== null)
|
|
209
|
+
.join(' · ')}
|
|
210
|
+
</p>
|
|
211
|
+
</li>
|
|
212
|
+
))}
|
|
213
|
+
</ul>
|
|
214
|
+
<div className="hidden md:block">
|
|
215
|
+
<Table>
|
|
216
|
+
<TableHeader>
|
|
217
|
+
<TableRow>
|
|
218
|
+
<TableHead>
|
|
219
|
+
{t('movethewheels.shipping.results.courier', 'Courier')}
|
|
220
|
+
</TableHead>
|
|
221
|
+
<TableHead>{t('movethewheels.shipping.results.mode', 'Mode')}</TableHead>
|
|
222
|
+
<TableHead>
|
|
223
|
+
{t('movethewheels.shipping.results.transit', 'Estimated delivery')}
|
|
224
|
+
</TableHead>
|
|
225
|
+
<TableHead>{t('movethewheels.shipping.results.rating', 'Rating')}</TableHead>
|
|
226
|
+
<TableHead className="text-right">
|
|
227
|
+
{t('movethewheels.shipping.results.price', 'Price')}
|
|
228
|
+
</TableHead>
|
|
229
|
+
</TableRow>
|
|
230
|
+
</TableHeader>
|
|
231
|
+
<TableBody>
|
|
232
|
+
{result.quotes.map((quote) => (
|
|
233
|
+
<TableRow key={`${quote.provider}:${quote.courierId}`}>
|
|
234
|
+
<TableCell>
|
|
235
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
236
|
+
<span className="font-medium">{quote.courierName}</span>
|
|
237
|
+
<QuoteBadges quote={quote} t={t} />
|
|
238
|
+
</div>
|
|
239
|
+
</TableCell>
|
|
240
|
+
<TableCell>{modeLabel(quote, t)}</TableCell>
|
|
241
|
+
<TableCell>{transit(quote, t)}</TableCell>
|
|
242
|
+
<TableCell>
|
|
243
|
+
{quote.rating === null ? '—' : quote.rating.toFixed(1)}
|
|
244
|
+
</TableCell>
|
|
245
|
+
<TableCell className="text-right font-medium">
|
|
246
|
+
{formatCurrency(quote.price, quote.currency)}
|
|
247
|
+
</TableCell>
|
|
248
|
+
</TableRow>
|
|
249
|
+
))}
|
|
250
|
+
</TableBody>
|
|
251
|
+
</Table>
|
|
252
|
+
</div>
|
|
253
|
+
</>
|
|
254
|
+
)}
|
|
255
|
+
</CardContent>
|
|
256
|
+
</Card>
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
@@ -13,7 +13,14 @@ export const OSM_ATTRIBUTION =
|
|
|
13
13
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
|
14
14
|
|
|
15
15
|
export type MapMarkerStatus =
|
|
16
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shipping rate comparison — every carrier's price for one pincode pair, on top
|
|
3
|
+
* of wspace-movethewheels-svc's carrier module. The carrier accounts belong to
|
|
4
|
+
* the platform, so there is nothing for a workspace to configure.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useCallback, useMemo } from 'react';
|
|
8
|
+
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
9
|
+
import { executeGraphQL } from '../utils/graphqlClient';
|
|
10
|
+
|
|
11
|
+
export type CarrierProvider = 'SHIPROCKET';
|
|
12
|
+
export type CarrierServiceMode = 'SURFACE' | 'AIR';
|
|
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
|
+
|
|
31
|
+
export interface ShippingQuoteInput {
|
|
32
|
+
pickupPostcode: string;
|
|
33
|
+
deliveryPostcode: string;
|
|
34
|
+
weightKg: number;
|
|
35
|
+
lengthCm?: number;
|
|
36
|
+
widthCm?: number;
|
|
37
|
+
heightCm?: number;
|
|
38
|
+
cod?: boolean;
|
|
39
|
+
ownFleet?: OwnFleetInput;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ShippingQuote {
|
|
43
|
+
provider: CarrierProvider;
|
|
44
|
+
courierId: string;
|
|
45
|
+
courierName: string;
|
|
46
|
+
price: number;
|
|
47
|
+
currency: string;
|
|
48
|
+
estimatedDeliveryDays: number | null;
|
|
49
|
+
estimatedDeliveryDate: string | null;
|
|
50
|
+
rating: number | null;
|
|
51
|
+
mode: CarrierServiceMode | null;
|
|
52
|
+
isRecommended: boolean;
|
|
53
|
+
isCheapest: boolean;
|
|
54
|
+
isFastest: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CarrierOutcome {
|
|
58
|
+
provider: CarrierProvider;
|
|
59
|
+
ok: boolean;
|
|
60
|
+
quoteCount: number;
|
|
61
|
+
errorMessage: string | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ShippingQuoteResult {
|
|
65
|
+
chargeableWeightKg: number;
|
|
66
|
+
ownFleet: OwnFleetEstimate | null;
|
|
67
|
+
quotes: ShippingQuote[];
|
|
68
|
+
providers: CarrierOutcome[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const SHIPPING_QUOTES_QUERY = `
|
|
72
|
+
query ShippingQuotes($input: ShippingQuoteInput!) {
|
|
73
|
+
shippingQuotes(input: $input) {
|
|
74
|
+
chargeableWeightKg
|
|
75
|
+
ownFleet {
|
|
76
|
+
distanceKm
|
|
77
|
+
costPerKm
|
|
78
|
+
fixedCost
|
|
79
|
+
cost
|
|
80
|
+
currency
|
|
81
|
+
savingsVsCheapestCourier
|
|
82
|
+
isCheaperThanCouriers
|
|
83
|
+
}
|
|
84
|
+
quotes {
|
|
85
|
+
provider
|
|
86
|
+
courierId
|
|
87
|
+
courierName
|
|
88
|
+
price
|
|
89
|
+
currency
|
|
90
|
+
estimatedDeliveryDays
|
|
91
|
+
estimatedDeliveryDate
|
|
92
|
+
rating
|
|
93
|
+
mode
|
|
94
|
+
isRecommended
|
|
95
|
+
isCheapest
|
|
96
|
+
isFastest
|
|
97
|
+
}
|
|
98
|
+
providers {
|
|
99
|
+
provider
|
|
100
|
+
ok
|
|
101
|
+
quoteCount
|
|
102
|
+
errorMessage
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
`;
|
|
107
|
+
|
|
108
|
+
function useGraphqlOptions() {
|
|
109
|
+
const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser } =
|
|
110
|
+
useMoveTheWheels();
|
|
111
|
+
return useMemo(
|
|
112
|
+
() => ({
|
|
113
|
+
apiGatewayUrl,
|
|
114
|
+
authToken,
|
|
115
|
+
workspaceToken,
|
|
116
|
+
workspaceId,
|
|
117
|
+
tenantId,
|
|
118
|
+
actorId: currentUser?.id,
|
|
119
|
+
}),
|
|
120
|
+
[apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser?.id]
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Imperative: a comparison is something the operator asks for, not page-load data. */
|
|
125
|
+
export function useShippingQuotes() {
|
|
126
|
+
const graphQLOptions = useGraphqlOptions();
|
|
127
|
+
return useCallback(
|
|
128
|
+
async (input: ShippingQuoteInput): Promise<ShippingQuoteResult> => {
|
|
129
|
+
const result = await executeGraphQL<{ shippingQuotes: ShippingQuoteResult }>(
|
|
130
|
+
graphQLOptions,
|
|
131
|
+
SHIPPING_QUOTES_QUERY,
|
|
132
|
+
{ input }
|
|
133
|
+
);
|
|
134
|
+
return result.shippingQuotes;
|
|
135
|
+
},
|
|
136
|
+
[graphQLOptions]
|
|
137
|
+
);
|
|
138
|
+
}
|