@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.
@@ -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
  '&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 {
@@ -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
+ }