@burdenoff/microfe-movethewheels 2026.1101.4 → 2026.1102.1

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,465 @@
1
+ /**
2
+ * Customer delivery notifications on the Settings page (BOFF-4402).
3
+ *
4
+ * Lets a workspace admin switch customer notifications on, pick email and/or
5
+ * SMS and the shipment events that notify, preview what customers receive,
6
+ * and see the most recent notifications with their delivery status. The
7
+ * service owns validation and delivery; this card only edits the settings.
8
+ *
9
+ * The parent keys this card by workspace, so a workspace switch remounts it.
10
+ * The form stays disabled until the saved settings have actually been read:
11
+ * the defaults are a placeholder, never something to edit and save over.
12
+ */
13
+ import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
14
+ import { BellRing, Loader2, RefreshCw, RotateCcw, Save } from 'lucide-react';
15
+ import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
16
+ import { executeGraphQL } from '../utils/graphqlClient';
17
+ import { formatDateTime } from '../utils/formatters';
18
+ import {
19
+ CUSTOMER_NOTIFICATION_EVENTS,
20
+ MTW_CUSTOMER_NOTIFICATIONS_QUERY,
21
+ MTW_CUSTOMER_NOTIFICATION_PREVIEWS_QUERY,
22
+ MTW_CUSTOMER_NOTIFICATION_SETTINGS_QUERY,
23
+ STATUS_LABELS,
24
+ UPDATE_MTW_CUSTOMER_NOTIFICATION_SETTINGS_MUTATION,
25
+ changedNotificationSettings,
26
+ defaultNotificationSettings,
27
+ eventLabel,
28
+ notificationDraftError,
29
+ toggleEvent,
30
+ type MtwCustomerNotification,
31
+ type MtwCustomerNotificationEvent,
32
+ type MtwCustomerNotificationPreview,
33
+ type MtwCustomerNotificationSettings,
34
+ type MtwCustomerNotificationStatus,
35
+ } from '../utils/customerNotifications';
36
+ import { FormError } from './FormError';
37
+ import { Notice, useNotice } from './Notice';
38
+ import {
39
+ Badge,
40
+ Button,
41
+ Card,
42
+ CardContent,
43
+ CardDescription,
44
+ CardHeader,
45
+ CardTitle,
46
+ Checkbox,
47
+ Label,
48
+ Skeleton,
49
+ Switch,
50
+ } from './ui';
51
+
52
+ const RECENT_LIMIT = 10;
53
+
54
+ type Draft = Pick<
55
+ MtwCustomerNotificationSettings,
56
+ 'enabled' | 'emailEnabled' | 'smsEnabled' | 'events'
57
+ >;
58
+
59
+ const toDraft = (settings: MtwCustomerNotificationSettings): Draft => ({
60
+ enabled: settings.enabled,
61
+ emailEnabled: settings.emailEnabled,
62
+ smsEnabled: settings.smsEnabled,
63
+ events: [...settings.events],
64
+ });
65
+
66
+ const STATUS_VARIANT: Record<
67
+ MtwCustomerNotificationStatus,
68
+ 'default' | 'secondary' | 'outline' | 'destructive'
69
+ > = {
70
+ SENT: 'default',
71
+ PENDING: 'secondary',
72
+ SKIPPED: 'outline',
73
+ FAILED: 'destructive',
74
+ };
75
+
76
+ export function CustomerNotificationSettingsCard({ readOnly }: { readOnly: boolean }) {
77
+ const { apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser } =
78
+ useMoveTheWheels();
79
+ const graphQLOptions = useMemo(
80
+ () => ({
81
+ apiGatewayUrl,
82
+ authToken,
83
+ workspaceToken,
84
+ workspaceId,
85
+ tenantId,
86
+ actorId: currentUser?.id,
87
+ }),
88
+ [apiGatewayUrl, authToken, workspaceToken, workspaceId, tenantId, currentUser?.id]
89
+ );
90
+
91
+ const [settings, setSettings] = useState<MtwCustomerNotificationSettings>(() =>
92
+ defaultNotificationSettings(workspaceId ?? '')
93
+ );
94
+ const [draft, setDraft] = useState<Draft>(() => toDraft(settings));
95
+ const [isLoaded, setIsLoaded] = useState(false);
96
+ const [loadError, setLoadError] = useState<string | null>(null);
97
+ const [isSaving, setIsSaving] = useState(false);
98
+ const [formError, setFormError] = useState<string | null>(null);
99
+ const { message: notice, notify } = useNotice();
100
+ const [recent, setRecent] = useState<MtwCustomerNotification[]>([]);
101
+ const [recentTotal, setRecentTotal] = useState(0);
102
+ const [recentError, setRecentError] = useState<string | null>(null);
103
+ // Starts true so the empty state does not flash before the first fetch resolves.
104
+ const [isLoadingRecent, setIsLoadingRecent] = useState(true);
105
+ const [previews, setPreviews] = useState<MtwCustomerNotificationPreview[]>([]);
106
+ const [previewEvent, setPreviewEvent] = useState<MtwCustomerNotificationEvent>('BOOKED');
107
+ const [loadAttempt, setLoadAttempt] = useState(0);
108
+ const recentRequest = useRef(0);
109
+
110
+ useEffect(() => {
111
+ if (!graphQLOptions.workspaceId) return;
112
+ let cancelled = false;
113
+ setIsLoaded(false);
114
+ executeGraphQL<{ mtwCustomerNotificationSettings: MtwCustomerNotificationSettings }>(
115
+ graphQLOptions,
116
+ MTW_CUSTOMER_NOTIFICATION_SETTINGS_QUERY
117
+ )
118
+ .then((data) => {
119
+ if (cancelled) return;
120
+ setSettings(data.mtwCustomerNotificationSettings);
121
+ setDraft(toDraft(data.mtwCustomerNotificationSettings));
122
+ setLoadError(null);
123
+ setIsLoaded(true);
124
+ })
125
+ .catch((caught: unknown) => {
126
+ if (!cancelled)
127
+ setLoadError(caught instanceof Error ? caught.message : 'Could not load the settings');
128
+ });
129
+ executeGraphQL<{ mtwCustomerNotificationPreviews: MtwCustomerNotificationPreview[] }>(
130
+ graphQLOptions,
131
+ MTW_CUSTOMER_NOTIFICATION_PREVIEWS_QUERY
132
+ )
133
+ .then((data) => {
134
+ if (!cancelled) setPreviews(data.mtwCustomerNotificationPreviews);
135
+ })
136
+ .catch(() => {
137
+ // Previews are a convenience; the settings still work without them.
138
+ });
139
+ return () => {
140
+ cancelled = true;
141
+ };
142
+ }, [graphQLOptions, loadAttempt]);
143
+
144
+ // Only the newest request may write the log: a slower, older response (or
145
+ // one that lands after unmount) is dropped.
146
+ const loadRecent = useCallback(async () => {
147
+ if (!graphQLOptions.workspaceId) {
148
+ setIsLoadingRecent(false);
149
+ return;
150
+ }
151
+ const request = ++recentRequest.current;
152
+ setIsLoadingRecent(true);
153
+ try {
154
+ const data = await executeGraphQL<{
155
+ mtwCustomerNotifications: { items: MtwCustomerNotification[]; total: number };
156
+ }>(graphQLOptions, MTW_CUSTOMER_NOTIFICATIONS_QUERY, {
157
+ pagination: { limit: RECENT_LIMIT, offset: 0 },
158
+ });
159
+ if (request !== recentRequest.current) return;
160
+ setRecent(data.mtwCustomerNotifications.items);
161
+ setRecentTotal(data.mtwCustomerNotifications.total);
162
+ setRecentError(null);
163
+ } catch (caught) {
164
+ if (request !== recentRequest.current) return;
165
+ setRecentError(caught instanceof Error ? caught.message : 'Could not load notifications');
166
+ } finally {
167
+ if (request === recentRequest.current) setIsLoadingRecent(false);
168
+ }
169
+ }, [graphQLOptions]);
170
+
171
+ useEffect(() => {
172
+ void loadRecent();
173
+ return () => {
174
+ // Invalidate whatever is in flight when the options change or the card unmounts.
175
+ recentRequest.current += 1;
176
+ };
177
+ }, [loadRecent]);
178
+
179
+ const changes = useMemo(() => changedNotificationSettings(settings, draft), [settings, draft]);
180
+ const draftError = notificationDraftError(draft);
181
+ const isDirty = Object.keys(changes).length > 0;
182
+ const canSave = isLoaded && !readOnly && !isSaving && isDirty && !draftError;
183
+ const disabled = readOnly || isSaving || !isLoaded;
184
+
185
+ const update = (next: Partial<Draft>) => {
186
+ setFormError(null);
187
+ setDraft((previous) => ({ ...previous, ...next }));
188
+ };
189
+
190
+ const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
191
+ event.preventDefault();
192
+ if (!canSave) return;
193
+ setIsSaving(true);
194
+ setFormError(null);
195
+ try {
196
+ const data = await executeGraphQL<{
197
+ updateMtwCustomerNotificationSettings: MtwCustomerNotificationSettings;
198
+ }>(
199
+ graphQLOptions,
200
+ UPDATE_MTW_CUSTOMER_NOTIFICATION_SETTINGS_MUTATION,
201
+ { input: changes },
202
+ 'SINGLE_ATTEMPT'
203
+ );
204
+ const saved = data.updateMtwCustomerNotificationSettings;
205
+ setSettings(saved);
206
+ setDraft(toDraft(saved));
207
+ notify(
208
+ saved.enabled
209
+ ? 'Customer notifications are on. Customers hear about shipment changes from now on.'
210
+ : 'Customer notifications are off.'
211
+ );
212
+ } catch (caught) {
213
+ setFormError(caught instanceof Error ? caught.message : 'Could not save the settings');
214
+ } finally {
215
+ setIsSaving(false);
216
+ }
217
+ };
218
+
219
+ const preview = previews.find((item) => item.event === previewEvent);
220
+
221
+ return (
222
+ <Card data-testid="mtw-customer-notifications">
223
+ <CardHeader>
224
+ <div className="flex flex-wrap items-center justify-between gap-3">
225
+ <CardTitle className="flex items-center gap-2 text-base">
226
+ <BellRing size={18} /> Customer delivery notifications
227
+ </CardTitle>
228
+ {isLoaded && (
229
+ <Badge variant={settings.enabled ? 'default' : 'outline'}>
230
+ {settings.enabled ? 'On' : 'Off'}
231
+ </Badge>
232
+ )}
233
+ </div>
234
+ <CardDescription>
235
+ Email or text your customers when their shipment is booked, picked up, out for delivery,
236
+ delivered, running late or could not be delivered. Each customer can opt out on their
237
+ customer record; SMS goes only to customers who agreed to texts.
238
+ </CardDescription>
239
+ </CardHeader>
240
+ <CardContent className="space-y-6">
241
+ {loadError && (
242
+ <div className="space-y-3">
243
+ <FormError
244
+ message={`Could not load the customer notification settings (${loadError}). They cannot be changed until they load.`}
245
+ />
246
+ <Button
247
+ type="button"
248
+ variant="outline"
249
+ size="sm"
250
+ onClick={() => {
251
+ setLoadError(null);
252
+ setLoadAttempt((attempt) => attempt + 1);
253
+ }}
254
+ >
255
+ <RefreshCw size={14} className="mr-2" /> Try again
256
+ </Button>
257
+ </div>
258
+ )}
259
+ <Notice message={notice} />
260
+
261
+ {!isLoaded ? (
262
+ loadError ? null : (
263
+ <div className="space-y-3" aria-busy="true">
264
+ <Skeleton className="h-8 w-full" />
265
+ <Skeleton className="h-24 w-full" />
266
+ </div>
267
+ )
268
+ ) : (
269
+ <form
270
+ className="space-y-6"
271
+ onSubmit={(event) => void onSubmit(event)}
272
+ aria-label="Customer notification settings"
273
+ >
274
+ {readOnly && (
275
+ <p className="rounded-lg border border-border-default bg-surface-secondary px-4 py-3 text-sm text-text-secondary">
276
+ You can view these settings. Ask a workspace admin or the Logistics Director to
277
+ change them.
278
+ </p>
279
+ )}
280
+ <div className="flex items-start justify-between gap-4 rounded-lg border border-border-default px-4 py-3">
281
+ <div className="min-w-0">
282
+ <Label htmlFor="mtw-customer-notify-enabled" className="text-sm font-medium">
283
+ Notify customers about their shipments
284
+ </Label>
285
+ <p className="text-xs text-text-secondary">
286
+ Only changes made after you switch this on notify; customers are never mailed
287
+ about shipments that already happened.
288
+ {settings.enabledAt && settings.enabled
289
+ ? ` On since ${formatDateTime(settings.enabledAt)}.`
290
+ : ''}
291
+ </p>
292
+ </div>
293
+ <Switch
294
+ id="mtw-customer-notify-enabled"
295
+ checked={draft.enabled}
296
+ disabled={disabled}
297
+ onCheckedChange={(checked: boolean) => update({ enabled: checked })}
298
+ />
299
+ </div>
300
+
301
+ <fieldset className="space-y-3" disabled={disabled}>
302
+ <legend className="text-sm font-semibold text-text-primary">Channels</legend>
303
+ <div className="flex flex-wrap gap-6">
304
+ <div className="flex items-center gap-2">
305
+ <Checkbox
306
+ id="mtw-customer-notify-email"
307
+ checked={draft.emailEnabled}
308
+ disabled={disabled}
309
+ onCheckedChange={(checked) => update({ emailEnabled: checked === true })}
310
+ />
311
+ <Label htmlFor="mtw-customer-notify-email" className="text-sm font-normal">
312
+ Email
313
+ </Label>
314
+ </div>
315
+ <div className="flex items-center gap-2">
316
+ <Checkbox
317
+ id="mtw-customer-notify-sms"
318
+ checked={draft.smsEnabled}
319
+ disabled={disabled}
320
+ onCheckedChange={(checked) => update({ smsEnabled: checked === true })}
321
+ />
322
+ <Label htmlFor="mtw-customer-notify-sms" className="text-sm font-normal">
323
+ SMS (phone numbers with a country code, e.g. +91…)
324
+ </Label>
325
+ </div>
326
+ </div>
327
+ <p className="text-xs text-text-secondary">
328
+ Texts are sent through the SMS channel configured for your organization. Without
329
+ one, SMS sends are recorded as failed in the recent notifications below.
330
+ </p>
331
+ </fieldset>
332
+
333
+ <fieldset className="space-y-3" disabled={disabled}>
334
+ <legend className="text-sm font-semibold text-text-primary">Events</legend>
335
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
336
+ {CUSTOMER_NOTIFICATION_EVENTS.map((option) => {
337
+ const id = `mtw-customer-notify-event-${option.value}`;
338
+ return (
339
+ <div key={option.value} className="flex items-start gap-2">
340
+ <Checkbox
341
+ id={id}
342
+ checked={draft.events.includes(option.value)}
343
+ disabled={disabled}
344
+ onCheckedChange={(checked) =>
345
+ update({
346
+ events: toggleEvent(draft.events, option.value, checked === true),
347
+ })
348
+ }
349
+ />
350
+ <Label htmlFor={id} className="text-sm font-normal leading-snug">
351
+ <span className="block font-medium text-text-primary">{option.label}</span>
352
+ <span className="block text-xs text-text-secondary">
353
+ {option.description}
354
+ </span>
355
+ </Label>
356
+ </div>
357
+ );
358
+ })}
359
+ </div>
360
+ </fieldset>
361
+
362
+ {draftError && <p className="text-xs text-status-error-text">{draftError}</p>}
363
+ <FormError message={formError} />
364
+
365
+ <div className="flex flex-wrap items-center justify-end gap-3">
366
+ <Button
367
+ type="button"
368
+ variant="outline"
369
+ disabled={!isDirty || isSaving}
370
+ onClick={() => {
371
+ setFormError(null);
372
+ setDraft(toDraft(settings));
373
+ }}
374
+ >
375
+ <RotateCcw size={16} className="mr-2" /> Discard changes
376
+ </Button>
377
+ <Button type="submit" disabled={!canSave}>
378
+ {isSaving ? (
379
+ <Loader2 size={16} className="mr-2 animate-spin" />
380
+ ) : (
381
+ <Save size={16} className="mr-2" />
382
+ )}
383
+ Save notification settings
384
+ </Button>
385
+ </div>
386
+ </form>
387
+ )}
388
+
389
+ {previews.length > 0 && (
390
+ <div className="space-y-3 border-t border-border-default pt-4">
391
+ <div className="flex flex-wrap items-center gap-2">
392
+ <span className="text-sm font-semibold text-text-primary">Preview</span>
393
+ {CUSTOMER_NOTIFICATION_EVENTS.map((option) => (
394
+ <Button
395
+ key={option.value}
396
+ type="button"
397
+ size="sm"
398
+ variant={previewEvent === option.value ? 'default' : 'outline'}
399
+ onClick={() => setPreviewEvent(option.value)}
400
+ >
401
+ {option.label}
402
+ </Button>
403
+ ))}
404
+ </div>
405
+ {preview && (
406
+ <div
407
+ className="space-y-2 rounded-lg bg-surface-secondary px-4 py-3 text-sm"
408
+ data-testid="mtw-customer-notify-preview"
409
+ >
410
+ <p className="font-medium text-text-primary">{preview.subject}</p>
411
+ <p className="whitespace-pre-line text-text-secondary">{preview.emailText}</p>
412
+ <p className="text-xs text-text-tertiary">SMS: {preview.smsText}</p>
413
+ </div>
414
+ )}
415
+ </div>
416
+ )}
417
+
418
+ <div className="space-y-3 border-t border-border-default pt-4">
419
+ <div className="flex flex-wrap items-center justify-between gap-2">
420
+ <span className="text-sm font-semibold text-text-primary">
421
+ Recent notifications{recentTotal > 0 ? ` (${recentTotal})` : ''}
422
+ </span>
423
+ <Button
424
+ type="button"
425
+ size="sm"
426
+ variant="outline"
427
+ disabled={isLoadingRecent}
428
+ onClick={() => void loadRecent()}
429
+ >
430
+ <RefreshCw size={14} className="mr-2" /> Refresh
431
+ </Button>
432
+ </div>
433
+ {recentError && <FormError message={recentError} />}
434
+ {!recentError && recent.length === 0 && !isLoadingRecent && (
435
+ <p className="text-sm text-text-secondary">
436
+ No customer notifications yet. They appear here as shipments change.
437
+ </p>
438
+ )}
439
+ {recent.length > 0 && (
440
+ <ul className="divide-y divide-border-default" data-testid="mtw-customer-notify-recent">
441
+ {recent.map((item) => (
442
+ <li key={item.id} className="flex flex-wrap items-start justify-between gap-2 py-2">
443
+ <div className="min-w-0">
444
+ <p className="text-sm font-medium text-text-primary">
445
+ {eventLabel(item.event)} · {item.shipmentNumber ?? item.shipmentId}
446
+ </p>
447
+ <p className="text-xs text-text-secondary">
448
+ {item.channel === 'EMAIL' ? 'Email' : 'SMS'}
449
+ {item.recipient ? ` to ${item.recipient}` : ''} ·{' '}
450
+ {formatDateTime(item.sentAt ?? item.createdAt)}
451
+ </p>
452
+ {item.lastError && item.status !== 'SENT' && (
453
+ <p className="text-xs text-text-tertiary">{item.lastError}</p>
454
+ )}
455
+ </div>
456
+ <Badge variant={STATUS_VARIANT[item.status]}>{STATUS_LABELS[item.status]}</Badge>
457
+ </li>
458
+ ))}
459
+ </ul>
460
+ )}
461
+ </div>
462
+ </CardContent>
463
+ </Card>
464
+ );
465
+ }
@@ -35,6 +35,8 @@ interface ApiCustomer {
35
35
  businessLicense?: string | null;
36
36
  taxId?: string | null;
37
37
  rating?: number | null;
38
+ notifyByEmail?: boolean | null;
39
+ notifyBySms?: boolean | null;
38
40
  createdAt: string;
39
41
  updatedAt: string;
40
42
  deletedAt?: string | null;
@@ -53,6 +55,8 @@ const LIST_CUSTOMERS_QUERY = `
53
55
  businessLicense
54
56
  taxId
55
57
  rating
58
+ notifyByEmail
59
+ notifyBySms
56
60
  createdAt
57
61
  updatedAt
58
62
  deletedAt
@@ -74,6 +78,8 @@ const GET_CUSTOMER_QUERY = `
74
78
  businessLicense
75
79
  taxId
76
80
  rating
81
+ notifyByEmail
82
+ notifyBySms
77
83
  createdAt
78
84
  updatedAt
79
85
  deletedAt
@@ -94,6 +100,8 @@ const CREATE_CUSTOMER_MUTATION = `
94
100
  businessLicense
95
101
  taxId
96
102
  rating
103
+ notifyByEmail
104
+ notifyBySms
97
105
  createdAt
98
106
  updatedAt
99
107
  deletedAt
@@ -114,6 +122,8 @@ const UPDATE_CUSTOMER_MUTATION = `
114
122
  businessLicense
115
123
  taxId
116
124
  rating
125
+ notifyByEmail
126
+ notifyBySms
117
127
  createdAt
118
128
  updatedAt
119
129
  deletedAt
@@ -144,6 +154,9 @@ function mapCustomer(customer: ApiCustomer): Customer {
144
154
  businessLicense: customer.businessLicense ?? undefined,
145
155
  taxId: customer.taxId ?? undefined,
146
156
  rating: customer.rating ?? undefined,
157
+ // Absent only from an older service; match its defaults (email on, SMS off).
158
+ notifyByEmail: customer.notifyByEmail ?? true,
159
+ notifyBySms: customer.notifyBySms ?? false,
147
160
  createdAt: new Date(customer.createdAt),
148
161
  updatedAt: new Date(customer.updatedAt),
149
162
  };
@@ -263,6 +276,8 @@ export function useCustomers(options: UseCustomersOptions = {}): UseCustomersRes
263
276
  businessLicense: customerData.businessLicense,
264
277
  taxId: customerData.taxId,
265
278
  rating: customerData.rating,
279
+ notifyByEmail: customerData.notifyByEmail,
280
+ notifyBySms: customerData.notifyBySms,
266
281
  },
267
282
  });
268
283
 
@@ -289,6 +304,8 @@ export function useCustomers(options: UseCustomersOptions = {}): UseCustomersRes
289
304
  businessLicense: updates.businessLicense,
290
305
  taxId: updates.taxId,
291
306
  rating: updates.rating,
307
+ notifyByEmail: updates.notifyByEmail,
308
+ notifyBySms: updates.notifyBySms,
292
309
  },
293
310
  });
294
311
 
@@ -18,6 +18,7 @@ import {
18
18
  Package,
19
19
  X,
20
20
  Trash2,
21
+ BellRing,
21
22
  } from 'lucide-react';
22
23
  import { PageLayout, LoadingState, ErrorState } from '../components/PageLayout';
23
24
  import { TypeBadge, StatusBadge } from '../components/StatusBadge';
@@ -51,6 +52,10 @@ import { useOrders } from '../hooks/useOrders';
51
52
  import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
52
53
  import { formatAddress, formatCurrency, formatDate } from '../utils/formatters';
53
54
  import { joinPath, buildOrderCustomerPath, getSafeCustomerReturnPath } from '../utils/navigation';
55
+ import {
56
+ CustomerDeliveryUpdatesFields,
57
+ deliveryUpdatesSummary,
58
+ } from '../components/CustomerDeliveryUpdatesFields';
54
59
  import { FormError } from '../components/FormError';
55
60
  import { Notice, useNotice } from '../components/Notice';
56
61
 
@@ -68,6 +73,8 @@ interface CustomerFormState {
68
73
  businessLicense: string;
69
74
  taxId: string;
70
75
  rating: string;
76
+ notifyByEmail: boolean;
77
+ notifyBySms: boolean;
71
78
  }
72
79
 
73
80
  const emptyCustomerForm: CustomerFormState = {
@@ -84,6 +91,8 @@ const emptyCustomerForm: CustomerFormState = {
84
91
  businessLicense: '',
85
92
  taxId: '',
86
93
  rating: '',
94
+ notifyByEmail: true,
95
+ notifyBySms: false,
87
96
  };
88
97
 
89
98
  function NewCustomerForm() {
@@ -125,6 +134,8 @@ function NewCustomerForm() {
125
134
  businessLicense: form.businessLicense.trim() || undefined,
126
135
  taxId: form.taxId.trim() || undefined,
127
136
  rating: form.rating ? Number(form.rating) : undefined,
137
+ notifyByEmail: form.notifyByEmail,
138
+ notifyBySms: form.notifyBySms,
128
139
  });
129
140
  const destination = returnTo
130
141
  ? buildOrderCustomerPath(returnTo, created.id)
@@ -320,6 +331,13 @@ function NewCustomerForm() {
320
331
  />
321
332
  </div>
322
333
  </div>
334
+ <div className="mt-6">
335
+ <CustomerDeliveryUpdatesFields
336
+ idPrefix="new-customer"
337
+ value={{ notifyByEmail: form.notifyByEmail, notifyBySms: form.notifyBySms }}
338
+ onChange={(next) => setForm((prev) => ({ ...prev, ...next }))}
339
+ />
340
+ </div>
323
341
  </CardContent>
324
342
  </Card>
325
343
  </form>
@@ -343,6 +361,8 @@ function customerToForm(customer: {
343
361
  businessLicense?: string;
344
362
  taxId?: string;
345
363
  rating?: number;
364
+ notifyByEmail: boolean;
365
+ notifyBySms: boolean;
346
366
  }): CustomerFormState {
347
367
  return {
348
368
  name: customer.name,
@@ -358,6 +378,8 @@ function customerToForm(customer: {
358
378
  businessLicense: customer.businessLicense ?? '',
359
379
  taxId: customer.taxId ?? '',
360
380
  rating: customer.rating ? String(customer.rating) : '',
381
+ notifyByEmail: customer.notifyByEmail,
382
+ notifyBySms: customer.notifyBySms,
361
383
  };
362
384
  }
363
385
 
@@ -424,6 +446,8 @@ export default function CustomerDetailsPage() {
424
446
  businessLicense: form.businessLicense.trim() || undefined,
425
447
  taxId: form.taxId.trim() || undefined,
426
448
  rating: form.rating ? Number(form.rating) : undefined,
449
+ notifyByEmail: form.notifyByEmail,
450
+ notifyBySms: form.notifyBySms,
427
451
  });
428
452
  notify('Customer updated.');
429
453
  setEditing(false);
@@ -662,6 +686,12 @@ export default function CustomerDetailsPage() {
662
686
  />
663
687
  </div>
664
688
  </div>
689
+ <CustomerDeliveryUpdatesFields
690
+ idPrefix="edit-customer"
691
+ value={{ notifyByEmail: form.notifyByEmail, notifyBySms: form.notifyBySms }}
692
+ onChange={(next) => setForm((prev) => ({ ...prev, ...next }))}
693
+ disabled={saving}
694
+ />
665
695
  </div>
666
696
  ) : (
667
697
  <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -691,6 +721,15 @@ export default function CustomerDetailsPage() {
691
721
  </div>
692
722
  </div>
693
723
  )}
724
+ <div className="flex items-center gap-3">
725
+ <BellRing size={18} className="text-text-secondary" />
726
+ <div>
727
+ <p className="text-sm text-text-secondary">Delivery updates</p>
728
+ <p className="font-medium" data-testid="customer-delivery-updates">
729
+ {deliveryUpdatesSummary(customer)}
730
+ </p>
731
+ </div>
732
+ </div>
694
733
  </div>
695
734
  </div>
696
735
  )}