@burdenoff/microfe-movethewheels 2026.1031.6 → 2026.1101.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/MoveTheWheelsRoot.js +141 -133
- package/dist/MoveTheWheelsRoutes.js +29 -25
- package/dist/components/EpodCaptureForm.js +256 -0
- package/dist/generated/wspace-types.d.ts +9012 -132
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/useCarrierIntegrations.js +141 -0
- package/dist/hooks/useDriverDay.js +153 -0
- package/dist/hooks/useOrders.js +1 -1
- package/dist/index.js +27 -26
- package/dist/movethewheels/components/EpodCaptureForm.d.ts +12 -0
- package/dist/movethewheels/hooks/index.d.ts +1 -0
- package/dist/movethewheels/hooks/useCarrierIntegrations.d.ts +39 -0
- package/dist/movethewheels/hooks/useDriverDay.d.ts +53 -0
- package/dist/movethewheels/pages/IntegrationsPage.d.ts +6 -0
- package/dist/movethewheels/utils/carrierIntegrations.d.ts +88 -0
- package/dist/movethewheels/utils/driverNavigation.d.ts +31 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/calendar-days.js +50 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/copy.js +16 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/pause.js +19 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/play.js +8 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/wifi-off.js +34 -0
- package/dist/pages/DeliveryCapturePage.js +450 -321
- package/dist/pages/IntegrationsPage.js +616 -0
- package/dist/pages/SettingsPage.js +168 -150
- package/dist/utils/carrierIntegrations.js +105 -0
- package/dist/utils/driverNavigation.js +51 -0
- package/dist/utils/formatters.js +18 -9
- package/package.json +4 -2
- package/src/generated/wspace-types.ts +10266 -142
- package/src/movethewheels/MoveTheWheelsRoot.tsx +8 -0
- package/src/movethewheels/MoveTheWheelsRoutes.tsx +4 -0
- package/src/movethewheels/components/EpodCaptureForm.tsx +343 -0
- package/src/movethewheels/hooks/index.ts +9 -0
- package/src/movethewheels/hooks/useCarrierIntegrations.ts +221 -0
- package/src/movethewheels/hooks/useDriverDay.ts +289 -0
- package/src/movethewheels/pages/DeliveryCapturePage.tsx +478 -392
- package/src/movethewheels/pages/IntegrationsPage.tsx +752 -0
- package/src/movethewheels/pages/SettingsPage.tsx +19 -3
- package/src/movethewheels/utils/carrierIntegrations.ts +252 -0
- package/src/movethewheels/utils/driverNavigation.ts +103 -0
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
Skeleton,
|
|
45
45
|
} from '../components/ui';
|
|
46
46
|
import { useMoveTheWheels } from '../providers/MoveTheWheelsProvider';
|
|
47
|
+
import { joinPath } from '../utils/navigation';
|
|
47
48
|
import { useModuleSettings } from '../providers/ModuleSettingsProvider';
|
|
48
49
|
import { formatCurrency, formatDateTime, formatDistance, formatWeight } from '../utils/formatters';
|
|
49
50
|
import {
|
|
@@ -111,8 +112,8 @@ const PLATFORM_LINKS = [
|
|
|
111
112
|
},
|
|
112
113
|
{
|
|
113
114
|
id: 'integrations',
|
|
114
|
-
title: '
|
|
115
|
-
description: '
|
|
115
|
+
title: 'Platform integrations',
|
|
116
|
+
description: 'ERP, messaging and app connections shared across products',
|
|
116
117
|
path: '/integrations/overview',
|
|
117
118
|
icon: Plug,
|
|
118
119
|
},
|
|
@@ -128,7 +129,7 @@ export default function SettingsPage() {
|
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
function SettingsWorkbench() {
|
|
131
|
-
const { navigate, workspaceId, currentUser } = useMoveTheWheels();
|
|
132
|
+
const { navigate, basePath, workspaceId, currentUser } = useMoveTheWheels();
|
|
132
133
|
const { settings, isLoaded, error: loadError, save, reload } = useModuleSettings();
|
|
133
134
|
const permissions = usePermissions();
|
|
134
135
|
const [draft, setDraft] = useState<Draft>(() => toDraft(settings));
|
|
@@ -487,6 +488,21 @@ function SettingsWorkbench() {
|
|
|
487
488
|
</CardDescription>
|
|
488
489
|
</CardHeader>
|
|
489
490
|
<CardContent className="space-y-2">
|
|
491
|
+
<button
|
|
492
|
+
type="button"
|
|
493
|
+
className="flex w-full items-start gap-3 rounded-lg border border-border-default px-3 py-2 text-left transition-colors hover:bg-surface-secondary"
|
|
494
|
+
onClick={() => navigate(joinPath(basePath, 'integrations'))}
|
|
495
|
+
>
|
|
496
|
+
<Plug size={18} className="mt-0.5 shrink-0 text-text-secondary" />
|
|
497
|
+
<span className="min-w-0 flex-1">
|
|
498
|
+
<span className="block text-sm font-medium text-text-primary">
|
|
499
|
+
Tracking integrations
|
|
500
|
+
</span>
|
|
501
|
+
<span className="block text-xs text-text-secondary">
|
|
502
|
+
Carrier, ELD and GPS feeds that update shipments automatically
|
|
503
|
+
</span>
|
|
504
|
+
</span>
|
|
505
|
+
</button>
|
|
490
506
|
{PLATFORM_LINKS.map((link) => {
|
|
491
507
|
const Icon = link.icon;
|
|
492
508
|
return (
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carrier and telematics integrations (BOFF-4404).
|
|
3
|
+
*
|
|
4
|
+
* A workspace connects an external carrier, ELD or GPS feed to MoveTheWheels
|
|
5
|
+
* as a signed webhook. Each delivery is verified with HMAC-SHA256 over the raw
|
|
6
|
+
* request body using the integration's signing secret, and every accepted
|
|
7
|
+
* update becomes a tracking event on the matching shipment.
|
|
8
|
+
*
|
|
9
|
+
* This module holds the GraphQL documents and the pure helpers the page uses;
|
|
10
|
+
* the network calls live in `hooks/useCarrierIntegrations`.
|
|
11
|
+
*/
|
|
12
|
+
import { resolveGraphqlUrl } from './graphqlClient';
|
|
13
|
+
|
|
14
|
+
/** Providers the service can ingest today. Vendor adapters are refused by the API. */
|
|
15
|
+
export const SUPPORTED_PROVIDERS = ['GENERIC_WEBHOOK'] as const;
|
|
16
|
+
export type SupportedProvider = (typeof SUPPORTED_PROVIDERS)[number];
|
|
17
|
+
|
|
18
|
+
/** Named networks that need a signed vendor agreement before an adapter can ship. */
|
|
19
|
+
export const ROADMAP_PROVIDERS = [
|
|
20
|
+
{ id: 'PROJECT44', label: 'project44' },
|
|
21
|
+
{ id: 'FOURKITES', label: 'FourKites' },
|
|
22
|
+
{ id: 'DESCARTES', label: 'Descartes' },
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
export const INTEGRATION_STATUSES = ['ACTIVE', 'PAUSED', 'DISABLED'] as const;
|
|
26
|
+
export type IntegrationStatus = (typeof INTEGRATION_STATUSES)[number];
|
|
27
|
+
|
|
28
|
+
export type IntegrationProvider = 'GENERIC_WEBHOOK' | 'PROJECT44' | 'FOURKITES' | 'DESCARTES';
|
|
29
|
+
|
|
30
|
+
export type IntegrationEventStatus =
|
|
31
|
+
'RECEIVED' | 'ACCEPTED' | 'PARTIAL' | 'REJECTED' | 'DUPLICATE' | 'CONFLICT';
|
|
32
|
+
|
|
33
|
+
/** Tracking event types a delivery may carry (the service's TrackingEventType). */
|
|
34
|
+
export const TRACKING_EVENT_TYPES = [
|
|
35
|
+
'CREATED',
|
|
36
|
+
'PICKED_UP',
|
|
37
|
+
'IN_TRANSIT',
|
|
38
|
+
'ARRIVED',
|
|
39
|
+
'DEPARTED',
|
|
40
|
+
'OUT_FOR_DELIVERY',
|
|
41
|
+
'DELIVERED',
|
|
42
|
+
'FAILED',
|
|
43
|
+
'RETURNED',
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
/** The service refuses shorter secrets; the generator always produces 64 hex characters. */
|
|
47
|
+
export const MIN_SECRET_LENGTH = 32;
|
|
48
|
+
|
|
49
|
+
/** Header the workspace gateway forwards; custom headers do not survive the gateway. */
|
|
50
|
+
export const SIGNATURE_HEADER = 'x-webhook-signature';
|
|
51
|
+
|
|
52
|
+
/** Updates accepted in one delivery (a JSON array of at most this many objects). */
|
|
53
|
+
export const MAX_UPDATES_PER_DELIVERY = 5;
|
|
54
|
+
|
|
55
|
+
export interface CarrierIntegration {
|
|
56
|
+
id: string;
|
|
57
|
+
workspaceId: string;
|
|
58
|
+
provider: IntegrationProvider;
|
|
59
|
+
name: string;
|
|
60
|
+
status: IntegrationStatus;
|
|
61
|
+
lastSyncAt: string | null;
|
|
62
|
+
lastErrorAt: string | null;
|
|
63
|
+
lastErrorMessage: string | null;
|
|
64
|
+
createdAt: string;
|
|
65
|
+
updatedAt: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CarrierIntegrationEvent {
|
|
69
|
+
id: string;
|
|
70
|
+
integrationId: string;
|
|
71
|
+
externalEventId: string | null;
|
|
72
|
+
shipmentId: string | null;
|
|
73
|
+
eventType: string | null;
|
|
74
|
+
status: IntegrationEventStatus;
|
|
75
|
+
errorMessage: string | null;
|
|
76
|
+
receivedAt: string;
|
|
77
|
+
processedAt: string | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const INTEGRATION_FIELDS = `
|
|
81
|
+
id
|
|
82
|
+
workspaceId
|
|
83
|
+
provider
|
|
84
|
+
name
|
|
85
|
+
status
|
|
86
|
+
lastSyncAt
|
|
87
|
+
lastErrorAt
|
|
88
|
+
lastErrorMessage
|
|
89
|
+
createdAt
|
|
90
|
+
updatedAt
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
export const LIST_INTEGRATIONS_QUERY = `
|
|
94
|
+
query MtwCarrierIntegrations($filter: IntegrationFilterInput, $pagination: PaginationInput) {
|
|
95
|
+
integrations(filter: $filter, pagination: $pagination) {
|
|
96
|
+
${INTEGRATION_FIELDS}
|
|
97
|
+
}
|
|
98
|
+
integrationsCount(filter: $filter)
|
|
99
|
+
}
|
|
100
|
+
`;
|
|
101
|
+
|
|
102
|
+
export const INTEGRATION_COUNTS_QUERY = `
|
|
103
|
+
query MtwCarrierIntegrationCounts {
|
|
104
|
+
all: integrationsCount
|
|
105
|
+
active: integrationsCount(filter: { status: ACTIVE })
|
|
106
|
+
}
|
|
107
|
+
`;
|
|
108
|
+
|
|
109
|
+
export const INTEGRATION_EVENTS_QUERY = `
|
|
110
|
+
query MtwCarrierIntegrationEvents($integrationId: ID, $pagination: PaginationInput) {
|
|
111
|
+
integrationEvents(integrationId: $integrationId, pagination: $pagination) {
|
|
112
|
+
id
|
|
113
|
+
integrationId
|
|
114
|
+
externalEventId
|
|
115
|
+
shipmentId
|
|
116
|
+
eventType
|
|
117
|
+
status
|
|
118
|
+
errorMessage
|
|
119
|
+
receivedAt
|
|
120
|
+
processedAt
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
`;
|
|
124
|
+
|
|
125
|
+
export const CREATE_INTEGRATION_MUTATION = `
|
|
126
|
+
mutation CreateMtwCarrierIntegration($input: CreateIntegrationInput!) {
|
|
127
|
+
createIntegration(input: $input) {
|
|
128
|
+
${INTEGRATION_FIELDS}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
`;
|
|
132
|
+
|
|
133
|
+
export const UPDATE_INTEGRATION_MUTATION = `
|
|
134
|
+
mutation UpdateMtwCarrierIntegration($id: ID!, $input: UpdateIntegrationInput!) {
|
|
135
|
+
updateIntegration(id: $id, input: $input) {
|
|
136
|
+
${INTEGRATION_FIELDS}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
`;
|
|
140
|
+
|
|
141
|
+
export const DELETE_INTEGRATION_MUTATION = `
|
|
142
|
+
mutation DeleteMtwCarrierIntegration($id: ID!) {
|
|
143
|
+
deleteIntegration(id: $id)
|
|
144
|
+
}
|
|
145
|
+
`;
|
|
146
|
+
|
|
147
|
+
/** Minimal source of randomness, so tests can supply a deterministic one. */
|
|
148
|
+
export interface RandomSource {
|
|
149
|
+
getRandomValues<T extends ArrayBufferView>(array: T): T;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A fresh 256-bit signing secret as 64 lowercase hex characters. */
|
|
153
|
+
export function generateSigningSecret(random: RandomSource = globalThis.crypto): string {
|
|
154
|
+
const bytes = random.getRandomValues(new Uint8Array(32));
|
|
155
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Returns an error message for an unusable secret, or null when it is fine. */
|
|
159
|
+
export function signingSecretError(secret: string): string | null {
|
|
160
|
+
if (secret.trim().length < MIN_SECRET_LENGTH) {
|
|
161
|
+
return `Use a signing secret of at least ${MIN_SECRET_LENGTH} characters.`;
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Returns an error message for an unusable name, or null when it is fine. */
|
|
167
|
+
export function integrationNameError(name: string): string | null {
|
|
168
|
+
const trimmed = name.trim();
|
|
169
|
+
if (!trimmed) return 'Give the integration a name.';
|
|
170
|
+
if (trimmed.length > 120) return 'Keep the name under 120 characters.';
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The public URL a carrier posts deliveries to. It lives on the same workspace
|
|
176
|
+
* gateway host the app already talks to: `/api/integrations/webhook/<workspace>/<integration>`.
|
|
177
|
+
* A relative gateway URL (local development behind the dev proxy) resolves
|
|
178
|
+
* against `fallbackOrigin`.
|
|
179
|
+
*/
|
|
180
|
+
export function webhookUrl(
|
|
181
|
+
apiGatewayUrl: string | undefined,
|
|
182
|
+
workspaceId: string,
|
|
183
|
+
integrationId: string,
|
|
184
|
+
fallbackOrigin: string
|
|
185
|
+
): string {
|
|
186
|
+
const origin = new URL(resolveGraphqlUrl(apiGatewayUrl), fallbackOrigin).origin;
|
|
187
|
+
return `${origin}/api/integrations/webhook/${workspaceId}/${integrationId}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** An example delivery body for the setup instructions. */
|
|
191
|
+
export function examplePayload(now: Date = new Date()): string {
|
|
192
|
+
return JSON.stringify(
|
|
193
|
+
{
|
|
194
|
+
externalEventId: 'evt_0001',
|
|
195
|
+
trackingNumber: 'YOUR-TRACKING-NUMBER',
|
|
196
|
+
eventType: 'IN_TRANSIT',
|
|
197
|
+
timestamp: now.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
198
|
+
location: { lat: 12.9716, lng: 77.5946, address: 'Bengaluru hub' },
|
|
199
|
+
details: 'Departed origin hub',
|
|
200
|
+
},
|
|
201
|
+
null,
|
|
202
|
+
2
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Quotes a value for a POSIX shell: single quotes, with embedded ones escaped. */
|
|
207
|
+
export function shellQuote(value: string): string {
|
|
208
|
+
return `'${value.split("'").join(`'\\''`)}'`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A copy-paste shell example that signs and sends one delivery. The secret is
|
|
213
|
+
* referenced as an environment variable so it never appears in the snippet.
|
|
214
|
+
*/
|
|
215
|
+
export function curlExample(url: string): string {
|
|
216
|
+
return [
|
|
217
|
+
`BODY='{"externalEventId":"evt_0001","trackingNumber":"YOUR-TRACKING-NUMBER","eventType":"IN_TRANSIT"}'`,
|
|
218
|
+
`SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$MTW_WEBHOOK_SECRET" -hex | sed 's/^.* //')`,
|
|
219
|
+
`curl -X POST ${shellQuote(url)} \\`,
|
|
220
|
+
` -H 'Content-Type: application/json' \\`,
|
|
221
|
+
` -H "${SIGNATURE_HEADER}: sha256=$SIG" \\`,
|
|
222
|
+
` --data "$BODY"`,
|
|
223
|
+
].join('\n');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** "OUT_FOR_DELIVERY" → "Out for delivery". */
|
|
227
|
+
export function humanize(value: string): string {
|
|
228
|
+
const text = value.toLowerCase().split('_').join(' ');
|
|
229
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export type BadgeTone = 'default' | 'secondary' | 'outline' | 'destructive';
|
|
233
|
+
|
|
234
|
+
export function integrationStatusTone(status: IntegrationStatus): BadgeTone {
|
|
235
|
+
if (status === 'ACTIVE') return 'default';
|
|
236
|
+
if (status === 'PAUSED') return 'secondary';
|
|
237
|
+
return 'outline';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function eventStatusTone(status: IntegrationEventStatus): BadgeTone {
|
|
241
|
+
if (status === 'ACCEPTED') return 'default';
|
|
242
|
+
if (status === 'DUPLICATE' || status === 'PARTIAL' || status === 'RECEIVED') return 'secondary';
|
|
243
|
+
return 'destructive';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** What a webhook sender sees for each status, shown next to the status control. */
|
|
247
|
+
export function statusExplanation(status: IntegrationStatus): string {
|
|
248
|
+
if (status === 'ACTIVE') return 'Signed deliveries are accepted and become tracking events.';
|
|
249
|
+
if (status === 'PAUSED')
|
|
250
|
+
return 'Deliveries are refused with HTTP 423 so the sender can retry after you resume.';
|
|
251
|
+
return 'Deliveries are refused with HTTP 410. Resume to accept them again.';
|
|
252
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn-by-turn navigation hand-off for the driver day view (BOFF-3991).
|
|
3
|
+
*
|
|
4
|
+
* The driver app does not embed its own routing engine: it hands the stop
|
|
5
|
+
* (or the remaining stops, in sequence) to the device's maps app through the
|
|
6
|
+
* universal Google Maps URL scheme, which opens the native Google Maps / Apple
|
|
7
|
+
* Maps-compatible handler on Android and iOS and a browser tab on desktop.
|
|
8
|
+
*/
|
|
9
|
+
import type { Address } from '../types';
|
|
10
|
+
import { formatAddress } from './formatters';
|
|
11
|
+
|
|
12
|
+
const MAPS_DIRECTIONS_BASE = 'https://www.google.com/maps/dir/?api=1';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Google's universal URL accepts at most 9 intermediate waypoints (plus the
|
|
16
|
+
* destination). Longer days are handed off in chunks: the driver restarts
|
|
17
|
+
* navigation from the next remaining stop once these are done.
|
|
18
|
+
*/
|
|
19
|
+
export const MAX_NAVIGATION_WAYPOINTS = 9;
|
|
20
|
+
|
|
21
|
+
/** The best destination string for an address: coordinates when present, else the formatted address. */
|
|
22
|
+
export function navigationDestination(address: Partial<Address> | null | undefined): string | null {
|
|
23
|
+
if (!address) return null;
|
|
24
|
+
if (
|
|
25
|
+
typeof address.latitude === 'number' &&
|
|
26
|
+
typeof address.longitude === 'number' &&
|
|
27
|
+
Number.isFinite(address.latitude) &&
|
|
28
|
+
Number.isFinite(address.longitude)
|
|
29
|
+
) {
|
|
30
|
+
return `${address.latitude},${address.longitude}`;
|
|
31
|
+
}
|
|
32
|
+
const formatted = formatAddress(address).trim();
|
|
33
|
+
return formatted.length > 0 ? formatted : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Directions from the device's current position to one stop. */
|
|
37
|
+
export function stopNavigationUrl(address: Partial<Address> | null | undefined): string | null {
|
|
38
|
+
const destination = navigationDestination(address);
|
|
39
|
+
if (!destination) return null;
|
|
40
|
+
const params = new URLSearchParams({ destination, travelmode: 'driving' });
|
|
41
|
+
return `${MAPS_DIRECTIONS_BASE}&${params.toString()}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Directions through the remaining stops in sequence: the last stop in the
|
|
46
|
+
* chunk is the destination, the ones before it are waypoints. Stops without a
|
|
47
|
+
* usable address are skipped. Returns null when no stop is navigable.
|
|
48
|
+
*/
|
|
49
|
+
export function routeNavigationUrl(
|
|
50
|
+
addresses: Array<Partial<Address> | null | undefined>
|
|
51
|
+
): string | null {
|
|
52
|
+
const destinations = addresses
|
|
53
|
+
.map((address) => navigationDestination(address))
|
|
54
|
+
.filter((value): value is string => value !== null)
|
|
55
|
+
.slice(0, MAX_NAVIGATION_WAYPOINTS + 1);
|
|
56
|
+
if (destinations.length === 0) return null;
|
|
57
|
+
const destination = destinations[destinations.length - 1];
|
|
58
|
+
const waypoints = destinations.slice(0, -1);
|
|
59
|
+
const params = new URLSearchParams({ destination, travelmode: 'driving' });
|
|
60
|
+
if (waypoints.length > 0) params.set('waypoints', waypoints.join('|'));
|
|
61
|
+
return `${MAPS_DIRECTIONS_BASE}&${params.toString()}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A `tel:` link for a phone number, keeping only dialable characters. */
|
|
65
|
+
export function phoneHref(phone: string | null | undefined): string | null {
|
|
66
|
+
if (!phone) return null;
|
|
67
|
+
const dialable = phone.replace(/[^\d+]/g, '');
|
|
68
|
+
return dialable.length >= 3 ? `tel:${dialable}` : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The device-local calendar date as YYYY-MM-DD (what `myDeliveryDay` expects). */
|
|
72
|
+
export function localDateString(date: Date): string {
|
|
73
|
+
const year = date.getFullYear();
|
|
74
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
75
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
76
|
+
return `${year}-${month}-${day}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Shift a YYYY-MM-DD date by whole days, staying in local calendar terms. */
|
|
80
|
+
export function shiftLocalDate(date: string, days: number): string {
|
|
81
|
+
const [year, month, day] = date.split('-').map(Number);
|
|
82
|
+
return localDateString(new Date(year, month - 1, day + days));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The `Date#getTimezoneOffset()` value for local midnight of a YYYY-MM-DD date (DST-correct). */
|
|
86
|
+
export function timezoneOffsetFor(date: string): number {
|
|
87
|
+
const [year, month, day] = date.split('-').map(Number);
|
|
88
|
+
return new Date(year, month - 1, day).getTimezoneOffset();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The device's IANA time zone (e.g. `Asia/Kolkata`), so the server can build
|
|
93
|
+
* the day window from real local midnights, including on DST transition
|
|
94
|
+
* days. Null when the runtime cannot report one; the fixed offset is then used.
|
|
95
|
+
*/
|
|
96
|
+
export function deviceTimeZone(): string | null {
|
|
97
|
+
try {
|
|
98
|
+
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
99
|
+
return typeof zone === 'string' && zone.length > 0 ? zone : null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|