@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,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shipping Rates Page
|
|
3
|
+
*
|
|
4
|
+
* Compare live courier rates between two pincodes across the platform's
|
|
5
|
+
* carriers (Shiprocket first). Nothing to set up: the carrier accounts belong
|
|
6
|
+
* to the platform. Read-only — comparing never books a shipment or spends
|
|
7
|
+
* carrier credit.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useState, type FormEvent } from 'react';
|
|
11
|
+
import { Scale } from 'lucide-react';
|
|
12
|
+
|
|
13
|
+
import { FormError } from '../components/FormError';
|
|
14
|
+
import { PageLayout } from '../components/PageLayout';
|
|
15
|
+
import { ShippingQuoteResults } from '../components/ShippingQuoteResults';
|
|
16
|
+
import {
|
|
17
|
+
Button,
|
|
18
|
+
Card,
|
|
19
|
+
CardContent,
|
|
20
|
+
CardHeader,
|
|
21
|
+
CardTitle,
|
|
22
|
+
Input,
|
|
23
|
+
Label,
|
|
24
|
+
Switch,
|
|
25
|
+
} from '../components/ui';
|
|
26
|
+
import { useTr } from '../hooks';
|
|
27
|
+
import {
|
|
28
|
+
useShippingQuotes,
|
|
29
|
+
type ShippingQuoteInput,
|
|
30
|
+
type ShippingQuoteResult,
|
|
31
|
+
} from '../hooks/useShippingCarriers';
|
|
32
|
+
|
|
33
|
+
const POSTCODE = /^[1-9]\d{5}$/;
|
|
34
|
+
|
|
35
|
+
const optionalNumber = (value: string): number | undefined => {
|
|
36
|
+
const parsed = Number(value);
|
|
37
|
+
return value.trim() !== '' && Number.isFinite(parsed) ? parsed : undefined;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export default function ShippingRatesPage() {
|
|
41
|
+
const tr = useTr();
|
|
42
|
+
const fetchQuotes = useShippingQuotes();
|
|
43
|
+
|
|
44
|
+
const [pickup, setPickup] = useState('');
|
|
45
|
+
const [delivery, setDelivery] = useState('');
|
|
46
|
+
const [weight, setWeight] = useState('');
|
|
47
|
+
const [length, setLength] = useState('');
|
|
48
|
+
const [width, setWidth] = useState('');
|
|
49
|
+
const [height, setHeight] = useState('');
|
|
50
|
+
const [cod, setCod] = useState(false);
|
|
51
|
+
const [distance, setDistance] = useState('');
|
|
52
|
+
const [costPerKm, setCostPerKm] = useState('');
|
|
53
|
+
const [fixedCost, setFixedCost] = useState('');
|
|
54
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
55
|
+
const [error, setError] = useState<string | null>(null);
|
|
56
|
+
const [result, setResult] = useState<ShippingQuoteResult | null>(null);
|
|
57
|
+
|
|
58
|
+
const handleCompare = async (event: FormEvent) => {
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
setError(null);
|
|
61
|
+
|
|
62
|
+
const weightKg = optionalNumber(weight);
|
|
63
|
+
if (!POSTCODE.test(pickup) || !POSTCODE.test(delivery)) {
|
|
64
|
+
setError(
|
|
65
|
+
tr(
|
|
66
|
+
'movethewheels.shipping.form.postcodeError',
|
|
67
|
+
'Enter valid 6-digit pickup and delivery postcodes'
|
|
68
|
+
)
|
|
69
|
+
);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (weightKg === undefined || weightKg <= 0) {
|
|
73
|
+
setError(tr('movethewheels.shipping.form.weightError', 'Enter the package weight in kg'));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const dims = [optionalNumber(length), optionalNumber(width), optionalNumber(height)];
|
|
77
|
+
const given = dims.filter((d) => d !== undefined).length;
|
|
78
|
+
if (given !== 0 && given !== 3) {
|
|
79
|
+
setError(
|
|
80
|
+
tr(
|
|
81
|
+
'movethewheels.shipping.form.dimensionsError',
|
|
82
|
+
'Enter length, width and height together, or leave all three empty'
|
|
83
|
+
)
|
|
84
|
+
);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const fleetDistance = optionalNumber(distance);
|
|
89
|
+
const fleetCostPerKm = optionalNumber(costPerKm);
|
|
90
|
+
const fleetFixed = optionalNumber(fixedCost);
|
|
91
|
+
const fleetGiven = fleetDistance !== undefined || fleetCostPerKm !== undefined;
|
|
92
|
+
if (
|
|
93
|
+
fleetGiven &&
|
|
94
|
+
(!fleetDistance || fleetDistance <= 0 || !fleetCostPerKm || fleetCostPerKm <= 0)
|
|
95
|
+
) {
|
|
96
|
+
setError(
|
|
97
|
+
tr(
|
|
98
|
+
'movethewheels.shipping.form.ownFleetError',
|
|
99
|
+
'To compare with your own vehicle, enter both the distance and your cost per km'
|
|
100
|
+
)
|
|
101
|
+
);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (fleetFixed !== undefined && fleetFixed < 0) {
|
|
105
|
+
setError(tr('movethewheels.shipping.form.fixedCostError', 'Fixed cost cannot be negative'));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const input: ShippingQuoteInput = {
|
|
110
|
+
pickupPostcode: pickup,
|
|
111
|
+
deliveryPostcode: delivery,
|
|
112
|
+
weightKg,
|
|
113
|
+
cod,
|
|
114
|
+
...(fleetGiven && fleetDistance && fleetCostPerKm
|
|
115
|
+
? {
|
|
116
|
+
ownFleet: {
|
|
117
|
+
distanceKm: fleetDistance,
|
|
118
|
+
costPerKm: fleetCostPerKm,
|
|
119
|
+
...(fleetFixed ? { fixedCost: fleetFixed } : {}),
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
: {}),
|
|
123
|
+
...(given === 3 ? { lengthCm: dims[0], widthCm: dims[1], heightCm: dims[2] } : {}),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
setIsLoading(true);
|
|
127
|
+
try {
|
|
128
|
+
setResult(await fetchQuotes(input));
|
|
129
|
+
} catch (e: unknown) {
|
|
130
|
+
setResult(null);
|
|
131
|
+
setError(
|
|
132
|
+
e instanceof Error
|
|
133
|
+
? e.message
|
|
134
|
+
: tr('movethewheels.shipping.form.failed', 'Could not fetch rates')
|
|
135
|
+
);
|
|
136
|
+
} finally {
|
|
137
|
+
setIsLoading(false);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<PageLayout
|
|
143
|
+
title={tr('movethewheels.shipping.title', 'Compare Rates')}
|
|
144
|
+
subtitle={tr(
|
|
145
|
+
'movethewheels.shipping.subtitle',
|
|
146
|
+
'Enter a pickup and a delivery pincode to see every courier available for that route, with price and delivery time.'
|
|
147
|
+
)}
|
|
148
|
+
icon={<Scale size={24} />}
|
|
149
|
+
>
|
|
150
|
+
<div className="space-y-6">
|
|
151
|
+
<Card>
|
|
152
|
+
<CardHeader>
|
|
153
|
+
<CardTitle>{tr('movethewheels.shipping.form.title', 'Shipment details')}</CardTitle>
|
|
154
|
+
</CardHeader>
|
|
155
|
+
<CardContent>
|
|
156
|
+
<form onSubmit={(event) => void handleCompare(event)} className="space-y-4">
|
|
157
|
+
<div className="grid gap-3 sm:grid-cols-3">
|
|
158
|
+
<div className="space-y-1">
|
|
159
|
+
<Label htmlFor="rate-pickup">
|
|
160
|
+
{tr('movethewheels.shipping.form.pickup', 'Pickup postcode')}
|
|
161
|
+
</Label>
|
|
162
|
+
<Input
|
|
163
|
+
id="rate-pickup"
|
|
164
|
+
inputMode="numeric"
|
|
165
|
+
maxLength={6}
|
|
166
|
+
value={pickup}
|
|
167
|
+
onChange={(e) => setPickup(e.target.value.trim())}
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
<div className="space-y-1">
|
|
171
|
+
<Label htmlFor="rate-delivery">
|
|
172
|
+
{tr('movethewheels.shipping.form.delivery', 'Delivery postcode')}
|
|
173
|
+
</Label>
|
|
174
|
+
<Input
|
|
175
|
+
id="rate-delivery"
|
|
176
|
+
inputMode="numeric"
|
|
177
|
+
maxLength={6}
|
|
178
|
+
value={delivery}
|
|
179
|
+
onChange={(e) => setDelivery(e.target.value.trim())}
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
<div className="space-y-1">
|
|
183
|
+
<Label htmlFor="rate-weight">
|
|
184
|
+
{tr('movethewheels.shipping.form.weight', 'Weight (kg)')}
|
|
185
|
+
</Label>
|
|
186
|
+
<Input
|
|
187
|
+
id="rate-weight"
|
|
188
|
+
inputMode="decimal"
|
|
189
|
+
value={weight}
|
|
190
|
+
onChange={(e) => setWeight(e.target.value)}
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
<div className="grid gap-3 sm:grid-cols-3">
|
|
195
|
+
<div className="space-y-1">
|
|
196
|
+
<Label htmlFor="rate-length">
|
|
197
|
+
{tr('movethewheels.shipping.form.length', 'Length (cm, optional)')}
|
|
198
|
+
</Label>
|
|
199
|
+
<Input
|
|
200
|
+
id="rate-length"
|
|
201
|
+
inputMode="decimal"
|
|
202
|
+
value={length}
|
|
203
|
+
onChange={(e) => setLength(e.target.value)}
|
|
204
|
+
/>
|
|
205
|
+
</div>
|
|
206
|
+
<div className="space-y-1">
|
|
207
|
+
<Label htmlFor="rate-width">
|
|
208
|
+
{tr('movethewheels.shipping.form.width', 'Width (cm, optional)')}
|
|
209
|
+
</Label>
|
|
210
|
+
<Input
|
|
211
|
+
id="rate-width"
|
|
212
|
+
inputMode="decimal"
|
|
213
|
+
value={width}
|
|
214
|
+
onChange={(e) => setWidth(e.target.value)}
|
|
215
|
+
/>
|
|
216
|
+
</div>
|
|
217
|
+
<div className="space-y-1">
|
|
218
|
+
<Label htmlFor="rate-height">
|
|
219
|
+
{tr('movethewheels.shipping.form.height', 'Height (cm, optional)')}
|
|
220
|
+
</Label>
|
|
221
|
+
<Input
|
|
222
|
+
id="rate-height"
|
|
223
|
+
inputMode="decimal"
|
|
224
|
+
value={height}
|
|
225
|
+
onChange={(e) => setHeight(e.target.value)}
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
<fieldset className="space-y-3 rounded-lg border p-4">
|
|
230
|
+
<legend className="px-1 text-sm font-medium">
|
|
231
|
+
{tr(
|
|
232
|
+
'movethewheels.shipping.form.ownFleetTitle',
|
|
233
|
+
'Compare with your own vehicle (optional)'
|
|
234
|
+
)}
|
|
235
|
+
</legend>
|
|
236
|
+
<p className="text-sm text-text-secondary">
|
|
237
|
+
{tr(
|
|
238
|
+
'movethewheels.shipping.form.ownFleetHelp',
|
|
239
|
+
'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.'
|
|
240
|
+
)}
|
|
241
|
+
</p>
|
|
242
|
+
<div className="grid gap-3 sm:grid-cols-3">
|
|
243
|
+
<div className="space-y-1">
|
|
244
|
+
<Label htmlFor="rate-distance">
|
|
245
|
+
{tr('movethewheels.shipping.form.distance', 'Distance (km)')}
|
|
246
|
+
</Label>
|
|
247
|
+
<Input
|
|
248
|
+
id="rate-distance"
|
|
249
|
+
inputMode="decimal"
|
|
250
|
+
value={distance}
|
|
251
|
+
onChange={(e) => setDistance(e.target.value)}
|
|
252
|
+
/>
|
|
253
|
+
</div>
|
|
254
|
+
<div className="space-y-1">
|
|
255
|
+
<Label htmlFor="rate-cost-per-km">
|
|
256
|
+
{tr('movethewheels.shipping.form.costPerKm', 'Your cost per km (₹)')}
|
|
257
|
+
</Label>
|
|
258
|
+
<Input
|
|
259
|
+
id="rate-cost-per-km"
|
|
260
|
+
inputMode="decimal"
|
|
261
|
+
value={costPerKm}
|
|
262
|
+
onChange={(e) => setCostPerKm(e.target.value)}
|
|
263
|
+
/>
|
|
264
|
+
</div>
|
|
265
|
+
<div className="space-y-1">
|
|
266
|
+
<Label htmlFor="rate-fixed-cost">
|
|
267
|
+
{tr(
|
|
268
|
+
'movethewheels.shipping.form.fixedCost',
|
|
269
|
+
'Fixed cost per trip (₹, optional)'
|
|
270
|
+
)}
|
|
271
|
+
</Label>
|
|
272
|
+
<Input
|
|
273
|
+
id="rate-fixed-cost"
|
|
274
|
+
inputMode="decimal"
|
|
275
|
+
value={fixedCost}
|
|
276
|
+
onChange={(e) => setFixedCost(e.target.value)}
|
|
277
|
+
/>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
</fieldset>
|
|
281
|
+
<div className="flex items-center gap-2">
|
|
282
|
+
<Switch id="rate-cod" checked={cod} onCheckedChange={setCod} />
|
|
283
|
+
<Label htmlFor="rate-cod">
|
|
284
|
+
{tr('movethewheels.shipping.form.cod', 'Cash on delivery')}
|
|
285
|
+
</Label>
|
|
286
|
+
</div>
|
|
287
|
+
<FormError message={error} />
|
|
288
|
+
<Button type="submit" disabled={isLoading}>
|
|
289
|
+
{isLoading
|
|
290
|
+
? tr('movethewheels.shipping.form.comparing', 'Comparing…')
|
|
291
|
+
: tr('movethewheels.shipping.form.compare', 'Compare rates')}
|
|
292
|
+
</Button>
|
|
293
|
+
</form>
|
|
294
|
+
</CardContent>
|
|
295
|
+
</Card>
|
|
296
|
+
|
|
297
|
+
{result && <ShippingQuoteResults result={result} t={tr} />}
|
|
298
|
+
</div>
|
|
299
|
+
</PageLayout>
|
|
300
|
+
);
|
|
301
|
+
}
|