@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.
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { useEffect, useState } from 'react';
8
- import { useParams } from 'react-router';
8
+ import { useParams, useSearchParams } from 'react-router';
9
9
  import { Package, ArrowLeft, Save, Plus, Trash2, Users, MapPin, CalendarClock } from 'lucide-react';
10
10
  import { PageLayout, EmptyState } from '../components/PageLayout';
11
11
  import { FormError } from '../components/FormError';
@@ -27,9 +27,11 @@ import {
27
27
  } from '../components/ui';
28
28
  import { EmphasisPanel, NextSteps, PagePurpose } from '@burdenoff/fe-libs/ui';
29
29
  import { useOrder, useOrders } from '../hooks/useOrders';
30
- import { useCustomers } from '../hooks/useCustomers';
30
+ import { useCustomer, useCustomers } from '../hooks/useCustomers';
31
31
  import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
32
- import { joinPath } from '../utils/navigation';
32
+ import { joinPath, buildCustomerCreatePath } from '../utils/navigation';
33
+ import { CustomerPicker } from '../components/CustomerPicker';
34
+ import type { Customer } from '../types';
33
35
 
34
36
  /**
35
37
  * `<input type="datetime-local">` needs a `YYYY-MM-DDTHH:mm` string in LOCAL
@@ -49,15 +51,25 @@ function toDateTimeLocalValue(date: Date | undefined): string {
49
51
 
50
52
  export default function CreateOrderPage() {
51
53
  const { orderId } = useParams<{ orderId: string }>();
54
+ const [searchParams] = useSearchParams();
55
+ const prefilledCustomerId = searchParams.get('customerId')?.trim() || undefined;
52
56
  const { basePath, navigate } = useMoveTheWheels();
53
57
  const { order } = useOrder(orderId);
54
58
  const { createOrder, updateOrder } = useOrders();
55
- const { customers } = useCustomers({ limit: 100 });
59
+ const {
60
+ customers,
61
+ createCustomer,
62
+ isLoading: customersLoading,
63
+ error: customersError,
64
+ refetch: refetchCustomers,
65
+ } = useCustomers({ limit: 100 });
66
+ const { customer: linkedCustomer } = useCustomer(prefilledCustomerId ?? order?.customerId);
56
67
 
57
68
  const isEditing = !!orderId && !!order;
58
69
 
70
+ const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
59
71
  const [formData, setFormData] = useState({
60
- customerId: order?.customerId || '',
72
+ customerId: prefilledCustomerId || order?.customerId || '',
61
73
  priority: order?.priority || 'normal',
62
74
  pickupAddress: {
63
75
  street: order?.pickupAddress.street || '',
@@ -81,6 +93,18 @@ export default function CreateOrderPage() {
81
93
  const [isSubmitting, setIsSubmitting] = useState(false);
82
94
  const [formError, setFormError] = useState<string | null>(null);
83
95
 
96
+ useEffect(() => {
97
+ if (!linkedCustomer) {
98
+ return;
99
+ }
100
+ setSelectedCustomer((previous) =>
101
+ previous?.id === linkedCustomer.id ? previous : linkedCustomer
102
+ );
103
+ setFormData((previous) =>
104
+ previous.customerId ? previous : { ...previous, customerId: linkedCustomer.id }
105
+ );
106
+ }, [linkedCustomer]);
107
+
84
108
  // Prefill the form from the loaded order when editing. `order` arrives
85
109
  // asynchronously after the initial render, so the useState initializers alone
86
110
  // leave the fields blank until this syncs them.
@@ -112,15 +136,24 @@ export default function CreateOrderPage() {
112
136
  }, [order]);
113
137
 
114
138
  const goBack = () => {
115
- if (navigate) {
116
- navigate(joinPath(basePath, 'orders'));
139
+ if (!navigate) {
140
+ return;
141
+ }
142
+ if (!isEditing && prefilledCustomerId) {
143
+ navigate(joinPath(basePath, `customers/${prefilledCustomerId}`));
144
+ return;
117
145
  }
146
+ navigate(joinPath(basePath, 'orders'));
118
147
  };
119
148
 
149
+ const getSelectedCustomer = () =>
150
+ selectedCustomer ?? customers.find((customer) => customer.id === formData.customerId);
151
+
120
152
  const validate = (): string | null => {
121
- const selectedCustomer = customers.find((c) => c.id === formData.customerId);
122
- if (!formData.customerId || !selectedCustomer) {
123
- return 'Select a customer for this order.';
153
+ if (!formData.customerId || !getSelectedCustomer()) {
154
+ return customersError
155
+ ? 'Customers could not be loaded. Retry or create a customer before continuing.'
156
+ : 'Select a customer for this order.';
124
157
  }
125
158
  if (!formData.pickupAddress.street.trim()) {
126
159
  return 'Enter a pickup street address.';
@@ -147,9 +180,13 @@ export default function CreateOrderPage() {
147
180
  return;
148
181
  }
149
182
 
150
- const selectedCustomer = customers.find((c) => c.id === formData.customerId);
183
+ const selectedCustomer = getSelectedCustomer();
151
184
  if (!selectedCustomer) {
152
- setFormError('Select a customer for this order.');
185
+ setFormError(
186
+ customersError
187
+ ? 'Customers could not be loaded. Retry or create a customer before continuing.'
188
+ : 'Select a customer for this order.'
189
+ );
153
190
  return;
154
191
  }
155
192
 
@@ -242,6 +279,14 @@ export default function CreateOrderPage() {
242
279
  }));
243
280
  };
244
281
 
282
+ const goToCustomerCreate = () => {
283
+ if (!navigate) {
284
+ return;
285
+ }
286
+ const orderPath = isEditing && orderId ? `orders/${orderId}/edit` : 'orders/new';
287
+ navigate(joinPath(basePath, buildCustomerCreatePath(orderPath)));
288
+ };
289
+
245
290
  return (
246
291
  <PageLayout
247
292
  title={isEditing ? 'Edit Order' : 'Create Order'}
@@ -253,7 +298,7 @@ export default function CreateOrderPage() {
253
298
  <ArrowLeft size={16} />
254
299
  Cancel
255
300
  </Button>
256
- <Button onClick={handleSubmit} disabled={isSubmitting}>
301
+ <Button onClick={handleSubmit} disabled={isSubmitting || customersLoading}>
257
302
  <Save size={16} />
258
303
  {isSubmitting ? 'Saving...' : isEditing ? 'Save Changes' : 'Create Order'}
259
304
  </Button>
@@ -304,24 +349,32 @@ export default function CreateOrderPage() {
304
349
  <h2 className="text-base font-semibold text-text-primary mb-4">Order Details</h2>
305
350
  <div className="space-y-4">
306
351
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
307
- <div className="space-y-2">
308
- <Label htmlFor="customer">Customer</Label>
309
- <Select
310
- value={formData.customerId}
311
- onValueChange={(value) => updateField('customerId', value)}
312
- >
313
- <SelectTrigger id="customer">
314
- <SelectValue placeholder="Select customer" />
315
- </SelectTrigger>
316
- <SelectContent>
317
- {customers.map((customer) => (
318
- <SelectItem key={customer.id} value={customer.id}>
319
- {customer.name} ({customer.email})
320
- </SelectItem>
321
- ))}
322
- </SelectContent>
323
- </Select>
324
- </div>
352
+ <CustomerPicker
353
+ customers={customers}
354
+ value={formData.customerId}
355
+ onValueChange={(value) => {
356
+ if (!value) {
357
+ return;
358
+ }
359
+ updateField('customerId', value);
360
+ setSelectedCustomer(
361
+ customers.find((customer) => customer.id === value) ??
362
+ (selectedCustomer?.id === value ? selectedCustomer : null)
363
+ );
364
+ setFormError(null);
365
+ }}
366
+ onCreated={(customer) => {
367
+ setSelectedCustomer(customer);
368
+ updateField('customerId', customer.id);
369
+ setFormError(null);
370
+ }}
371
+ createCustomer={createCustomer}
372
+ isLoading={customersLoading}
373
+ error={customersError}
374
+ onRetry={refetchCustomers}
375
+ onOpenCustomers={goToCustomerCreate}
376
+ selectedCustomer={selectedCustomer}
377
+ />
325
378
  <div className="space-y-2">
326
379
  <Label htmlFor="priority">Priority</Label>
327
380
  <Select
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { useEffect, useState } from 'react';
6
- import { useParams } from 'react-router';
6
+ import { useParams, useSearchParams } from 'react-router';
7
7
  import {
8
8
  Users,
9
9
  ArrowLeft,
@@ -40,7 +40,7 @@ import { useCustomer, useCustomers } from '../hooks/useCustomers';
40
40
  import { useOrders } from '../hooks/useOrders';
41
41
  import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
42
42
  import { formatAddress, formatCurrency, formatDate } from '../utils/formatters';
43
- import { joinPath } from '../utils/navigation';
43
+ import { joinPath, buildOrderCustomerPath, getSafeCustomerReturnPath } from '../utils/navigation';
44
44
  import { FormError } from '../components/FormError';
45
45
  import { Notice, useNotice } from '../components/Notice';
46
46
 
@@ -62,6 +62,8 @@ function safeHttpUrl(raw: string | undefined): string | null {
62
62
 
63
63
  function NewCustomerForm() {
64
64
  const { basePath, navigate } = useMoveTheWheels();
65
+ const [searchParams] = useSearchParams();
66
+ const returnTo = getSafeCustomerReturnPath(searchParams.get('returnTo'));
65
67
  const { createCustomer } = useCustomers();
66
68
  const [form, setForm] = useState({
67
69
  name: '',
@@ -91,7 +93,10 @@ function NewCustomerForm() {
91
93
  phone: form.phone.trim() || undefined,
92
94
  company: form.company.trim() || undefined,
93
95
  });
94
- navigate?.(joinPath(basePath, `customers/${created.id}`));
96
+ const destination = returnTo
97
+ ? buildOrderCustomerPath(returnTo, created.id)
98
+ : `customers/${created.id}`;
99
+ navigate?.(joinPath(basePath, destination));
95
100
  } catch (err) {
96
101
  console.error('Failed to create customer:', err);
97
102
  setError(err instanceof Error ? err.message : 'Failed to create customer. Please try again.');
@@ -330,7 +335,7 @@ export default function CustomerDetailsPage() {
330
335
  label: 'Book a delivery for this customer',
331
336
  description: 'Start a new order pre-filled for this account.',
332
337
  icon: <FileText size={16} />,
333
- onClick: () => goTo('orders/new'),
338
+ onClick: () => goTo(buildOrderCustomerPath('orders/new', customer.id)),
334
339
  },
335
340
  {
336
341
  id: 'edit',
@@ -475,7 +480,11 @@ export default function CustomerDetailsPage() {
475
480
  <CardTitle className="flex items-center gap-2">
476
481
  <Package size={18} /> Orders
477
482
  </CardTitle>
478
- <Button variant="outline" size="sm" onClick={() => goTo('orders/new')}>
483
+ <Button
484
+ variant="outline"
485
+ size="sm"
486
+ onClick={() => goTo(buildOrderCustomerPath('orders/new', customer.id))}
487
+ >
479
488
  <FileText size={14} /> New order
480
489
  </Button>
481
490
  </CardHeader>
@@ -508,7 +517,7 @@ export default function CustomerDetailsPage() {
508
517
  <p className="text-text-secondary">No orders yet for this customer</p>
509
518
  <button
510
519
  type="button"
511
- onClick={() => goTo('orders/new')}
520
+ onClick={() => goTo(buildOrderCustomerPath('orders/new', customer.id))}
512
521
  className="mt-1 text-sm text-primary hover:underline"
513
522
  >
514
523
  Book their first delivery
@@ -48,6 +48,9 @@ export default function ShippingRatesPage() {
48
48
  const [width, setWidth] = useState('');
49
49
  const [height, setHeight] = useState('');
50
50
  const [cod, setCod] = useState(false);
51
+ const [distance, setDistance] = useState('');
52
+ const [costPerKm, setCostPerKm] = useState('');
53
+ const [fixedCost, setFixedCost] = useState('');
51
54
  const [isLoading, setIsLoading] = useState(false);
52
55
  const [error, setError] = useState<string | null>(null);
53
56
  const [result, setResult] = useState<ShippingQuoteResult | null>(null);
@@ -82,11 +85,41 @@ export default function ShippingRatesPage() {
82
85
  return;
83
86
  }
84
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
+
85
109
  const input: ShippingQuoteInput = {
86
110
  pickupPostcode: pickup,
87
111
  deliveryPostcode: delivery,
88
112
  weightKg,
89
113
  cod,
114
+ ...(fleetGiven && fleetDistance && fleetCostPerKm
115
+ ? {
116
+ ownFleet: {
117
+ distanceKm: fleetDistance,
118
+ costPerKm: fleetCostPerKm,
119
+ ...(fleetFixed ? { fixedCost: fleetFixed } : {}),
120
+ },
121
+ }
122
+ : {}),
90
123
  ...(given === 3 ? { lengthCm: dims[0], widthCm: dims[1], heightCm: dims[2] } : {}),
91
124
  };
92
125
 
@@ -193,6 +226,58 @@ export default function ShippingRatesPage() {
193
226
  />
194
227
  </div>
195
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>
196
281
  <div className="flex items-center gap-2">
197
282
  <Switch id="rate-cod" checked={cod} onCheckedChange={setCod} />
198
283
  <Label htmlFor="rate-cod">
@@ -68,3 +68,35 @@ export function parseQueryParams(search: string): Record<string, string> {
68
68
  });
69
69
  return params;
70
70
  }
71
+
72
+ const CUSTOMER_RETURN_PATHS = [/^orders\/new$/, /^orders\/[^/?#]+\/edit$/];
73
+
74
+ /**
75
+ * Return only an internal order destination for customer create-and-return flows.
76
+ * Query strings are intentionally discarded so callers cannot smuggle a second
77
+ * return target or an open redirect through the customer form.
78
+ */
79
+ export function getSafeCustomerReturnPath(value: string | null | undefined): string | null {
80
+ if (!value || value.length > 200 || value.includes('://') || value.startsWith('/')) {
81
+ return null;
82
+ }
83
+
84
+ const [pathname] = value.split(/[?#]/, 1);
85
+ return CUSTOMER_RETURN_PATHS.some((pattern) => pattern.test(pathname)) ? pathname : null;
86
+ }
87
+
88
+ /**
89
+ * Build an order route with a customer preselected after a customer is created.
90
+ */
91
+ export function buildOrderCustomerPath(orderPath: string, customerId: string): string {
92
+ const safeOrderPath = getSafeCustomerReturnPath(orderPath);
93
+ return safeOrderPath ? buildUrl(safeOrderPath, { customerId }) : orderPath;
94
+ }
95
+
96
+ /**
97
+ * Build the local customer creation route while preserving a safe order return path.
98
+ */
99
+ export function buildCustomerCreatePath(orderPath: string): string {
100
+ const safeOrderPath = getSafeCustomerReturnPath(orderPath);
101
+ return safeOrderPath ? buildUrl('customers/new', { returnTo: safeOrderPath }) : 'customers/new';
102
+ }