@burdenoff/microfe-movethewheels 2026.830.5 → 2026.920.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.
- package/dist/MoveTheWheelsRoutes.js +7 -3
- package/dist/components/AssignmentSuggestions.js +135 -0
- package/dist/components/FleetAlertsPanel.js +114 -0
- package/dist/components/ShippingQuoteResults.js +142 -0
- package/dist/components/VehicleServiceCard.js +176 -0
- package/dist/hooks/useFleet.js +4 -3
- package/dist/hooks/useFleetIntelligence.js +91 -0
- package/dist/hooks/useShippingCarriers.js +29 -0
- package/dist/movethewheels/components/AssignmentSuggestions.d.ts +17 -0
- package/dist/movethewheels/components/FleetAlertsPanel.d.ts +14 -0
- package/dist/movethewheels/components/ShippingQuoteResults.d.ts +8 -0
- package/dist/movethewheels/components/VehicleServiceCard.d.ts +10 -0
- package/dist/movethewheels/hooks/useFleetIntelligence.d.ts +64 -0
- package/dist/movethewheels/hooks/useShippingCarriers.d.ts +43 -0
- package/dist/movethewheels/pages/ShippingRatesPage.d.ts +9 -0
- package/dist/movethewheels/types/index.d.ts +2 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/scale.js +26 -0
- package/dist/node_modules/lucide-react/dist/esm/icons/sparkles.js +26 -0
- package/dist/pages/FleetPage.js +156 -146
- package/dist/pages/RouteDetailsPage.js +202 -184
- package/dist/pages/ShippingRatesPage.js +177 -0
- package/dist/pages/VehicleDetailsPage.js +149 -131
- package/dist/utils/graphqlClient.js +14 -5
- package/package.json +1 -1
- package/src/movethewheels/MoveTheWheelsRoutes.tsx +2 -0
- package/src/movethewheels/components/AssignmentSuggestions.tsx +234 -0
- package/src/movethewheels/components/FleetAlertsPanel.tsx +148 -0
- package/src/movethewheels/components/ShippingQuoteResults.tsx +199 -0
- package/src/movethewheels/components/VehicleServiceCard.tsx +220 -0
- package/src/movethewheels/hooks/useFleet.ts +12 -2
- package/src/movethewheels/hooks/useFleetIntelligence.ts +226 -0
- package/src/movethewheels/hooks/useShippingCarriers.ts +110 -0
- package/src/movethewheels/pages/FleetPage.tsx +21 -0
- package/src/movethewheels/pages/RouteDetailsPage.tsx +33 -1
- package/src/movethewheels/pages/ShippingRatesPage.tsx +216 -0
- package/src/movethewheels/pages/VehicleDetailsPage.tsx +27 -0
- package/src/movethewheels/types/index.ts +2 -0
- package/src/movethewheels/utils/graphqlClient.ts +20 -1
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AssignmentSuggestions
|
|
3
|
+
*
|
|
4
|
+
* Ranked vehicles and drivers that can take a route right now, each with the
|
|
5
|
+
* reasons behind the ranking, and a one-click assign. Ranking and eligibility
|
|
6
|
+
* (in service, licence valid, not already on a live route) are decided
|
|
7
|
+
* server-side by `assignmentSuggestions`; the server also re-checks the same
|
|
8
|
+
* guardrails when the assignment is saved, so a stale suggestion is rejected
|
|
9
|
+
* with a clear message rather than silently applied.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useState } from 'react';
|
|
13
|
+
import { Sparkles } from 'lucide-react';
|
|
14
|
+
|
|
15
|
+
import { FormError } from './FormError';
|
|
16
|
+
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Skeleton } from './ui';
|
|
17
|
+
|
|
18
|
+
import type { AssignmentSuggestions as Suggestions } from '../hooks/useFleetIntelligence';
|
|
19
|
+
|
|
20
|
+
interface AssignmentSuggestionsProps {
|
|
21
|
+
suggestions: Suggestions;
|
|
22
|
+
isLoading: boolean;
|
|
23
|
+
error: Error | null;
|
|
24
|
+
onRetry: () => void;
|
|
25
|
+
assignedVehicleId?: string;
|
|
26
|
+
assignedDriverId?: string;
|
|
27
|
+
onAssign: (patch: { vehicleId?: string; driverId?: string }) => Promise<void>;
|
|
28
|
+
t: (key: string, fallback: string) => string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface RowProps {
|
|
32
|
+
id: string;
|
|
33
|
+
label: string;
|
|
34
|
+
reasons: string[];
|
|
35
|
+
isBest: boolean;
|
|
36
|
+
isAssigned: boolean;
|
|
37
|
+
isPending: boolean;
|
|
38
|
+
disabled: boolean;
|
|
39
|
+
onAssign: () => void;
|
|
40
|
+
t: (key: string, fallback: string) => string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function SuggestionRow({
|
|
44
|
+
id,
|
|
45
|
+
label,
|
|
46
|
+
reasons,
|
|
47
|
+
isBest,
|
|
48
|
+
isAssigned,
|
|
49
|
+
isPending,
|
|
50
|
+
disabled,
|
|
51
|
+
onAssign,
|
|
52
|
+
t,
|
|
53
|
+
}: RowProps) {
|
|
54
|
+
return (
|
|
55
|
+
<li className="flex items-start justify-between gap-3 py-3" data-suggestion-id={id}>
|
|
56
|
+
<div className="flex-1 min-w-0 space-y-1">
|
|
57
|
+
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
58
|
+
<span className="text-sm font-medium break-words">{label}</span>
|
|
59
|
+
{isBest && (
|
|
60
|
+
<Badge variant="secondary">
|
|
61
|
+
{t('movethewheels.routes.suggest.best', 'Best match')}
|
|
62
|
+
</Badge>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
<ul className="space-y-0.5">
|
|
66
|
+
{reasons.map((reason) => (
|
|
67
|
+
<li key={reason} className="text-xs text-text-secondary">
|
|
68
|
+
{reason}
|
|
69
|
+
</li>
|
|
70
|
+
))}
|
|
71
|
+
</ul>
|
|
72
|
+
</div>
|
|
73
|
+
<Button
|
|
74
|
+
size="sm"
|
|
75
|
+
className="shrink-0"
|
|
76
|
+
variant={isAssigned ? 'ghost' : 'outline'}
|
|
77
|
+
disabled={disabled || isAssigned}
|
|
78
|
+
onClick={onAssign}
|
|
79
|
+
>
|
|
80
|
+
{isAssigned
|
|
81
|
+
? t('movethewheels.routes.suggest.assigned', 'Assigned')
|
|
82
|
+
: isPending
|
|
83
|
+
? t('movethewheels.routes.suggest.assigning', 'Assigning…')
|
|
84
|
+
: t('movethewheels.routes.suggest.assign', 'Assign')}
|
|
85
|
+
</Button>
|
|
86
|
+
</li>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function AssignmentSuggestions({
|
|
91
|
+
suggestions,
|
|
92
|
+
isLoading,
|
|
93
|
+
error,
|
|
94
|
+
onRetry,
|
|
95
|
+
assignedVehicleId,
|
|
96
|
+
assignedDriverId,
|
|
97
|
+
onAssign,
|
|
98
|
+
t,
|
|
99
|
+
}: AssignmentSuggestionsProps) {
|
|
100
|
+
const [pendingId, setPendingId] = useState<string | null>(null);
|
|
101
|
+
const [assignError, setAssignError] = useState<string | null>(null);
|
|
102
|
+
const title = t('movethewheels.routes.suggest.title', 'Suggested assignment');
|
|
103
|
+
|
|
104
|
+
const assign = async (id: string, patch: { vehicleId?: string; driverId?: string }) => {
|
|
105
|
+
setPendingId(id);
|
|
106
|
+
setAssignError(null);
|
|
107
|
+
try {
|
|
108
|
+
await onAssign(patch);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
setAssignError(e instanceof Error ? e.message : 'Could not assign.');
|
|
111
|
+
} finally {
|
|
112
|
+
setPendingId(null);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (error && !isLoading) {
|
|
117
|
+
return (
|
|
118
|
+
<Card className="border-status-error-border">
|
|
119
|
+
<CardContent className="p-6 text-center space-y-3">
|
|
120
|
+
<p className="text-status-error-text font-medium">
|
|
121
|
+
{t('movethewheels.routes.suggest.error', "Couldn't load suggestions")}
|
|
122
|
+
</p>
|
|
123
|
+
<p className="text-sm text-text-secondary">{error.message}</p>
|
|
124
|
+
<Button variant="outline" onClick={onRetry}>
|
|
125
|
+
{t('movethewheels.fleet.error.retry', 'Retry')}
|
|
126
|
+
</Button>
|
|
127
|
+
</CardContent>
|
|
128
|
+
</Card>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (isLoading) {
|
|
133
|
+
return (
|
|
134
|
+
<Card>
|
|
135
|
+
<CardHeader>
|
|
136
|
+
<CardTitle>{title}</CardTitle>
|
|
137
|
+
</CardHeader>
|
|
138
|
+
<CardContent className="space-y-3">
|
|
139
|
+
<Skeleton className="h-14 w-full" />
|
|
140
|
+
<Skeleton className="h-14 w-full" />
|
|
141
|
+
</CardContent>
|
|
142
|
+
</Card>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const { vehicles, drivers } = suggestions;
|
|
147
|
+
const nothing = vehicles.length === 0 && drivers.length === 0;
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<Card>
|
|
151
|
+
<CardHeader>
|
|
152
|
+
<CardTitle className="flex items-center gap-2">
|
|
153
|
+
<Sparkles size={18} aria-hidden="true" />
|
|
154
|
+
{title}
|
|
155
|
+
</CardTitle>
|
|
156
|
+
</CardHeader>
|
|
157
|
+
<CardContent className="space-y-4">
|
|
158
|
+
<FormError message={assignError} />
|
|
159
|
+
|
|
160
|
+
{nothing ? (
|
|
161
|
+
<p className="text-sm text-text-secondary">
|
|
162
|
+
{t(
|
|
163
|
+
'movethewheels.routes.suggest.none',
|
|
164
|
+
'No vehicle or driver is free right now. Vehicles must be in service and drivers must hold a valid licence and not be on another live route.'
|
|
165
|
+
)}
|
|
166
|
+
</p>
|
|
167
|
+
) : (
|
|
168
|
+
<>
|
|
169
|
+
<section aria-label={t('movethewheels.routes.suggest.vehicles', 'Vehicles')}>
|
|
170
|
+
<h4 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
|
|
171
|
+
{t('movethewheels.routes.suggest.vehicles', 'Vehicles')}
|
|
172
|
+
</h4>
|
|
173
|
+
{vehicles.length === 0 ? (
|
|
174
|
+
<p className="py-3 text-sm text-text-secondary">
|
|
175
|
+
{t('movethewheels.routes.suggest.noVehicles', 'No vehicle is free right now.')}
|
|
176
|
+
</p>
|
|
177
|
+
) : (
|
|
178
|
+
<ul className="divide-y">
|
|
179
|
+
{vehicles.map((s, index) => (
|
|
180
|
+
<SuggestionRow
|
|
181
|
+
key={s.vehicle.id}
|
|
182
|
+
id={s.vehicle.id}
|
|
183
|
+
label={[
|
|
184
|
+
s.vehicle.vehicleNumber,
|
|
185
|
+
[s.vehicle.make, s.vehicle.model].filter(Boolean).join(' '),
|
|
186
|
+
]
|
|
187
|
+
.filter(Boolean)
|
|
188
|
+
.join(' — ')}
|
|
189
|
+
reasons={s.reasons}
|
|
190
|
+
isBest={index === 0}
|
|
191
|
+
isAssigned={assignedVehicleId === s.vehicle.id}
|
|
192
|
+
isPending={pendingId === s.vehicle.id}
|
|
193
|
+
disabled={pendingId !== null}
|
|
194
|
+
onAssign={() => assign(s.vehicle.id, { vehicleId: s.vehicle.id })}
|
|
195
|
+
t={t}
|
|
196
|
+
/>
|
|
197
|
+
))}
|
|
198
|
+
</ul>
|
|
199
|
+
)}
|
|
200
|
+
</section>
|
|
201
|
+
|
|
202
|
+
<section aria-label={t('movethewheels.routes.suggest.drivers', 'Drivers')}>
|
|
203
|
+
<h4 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
|
|
204
|
+
{t('movethewheels.routes.suggest.drivers', 'Drivers')}
|
|
205
|
+
</h4>
|
|
206
|
+
{drivers.length === 0 ? (
|
|
207
|
+
<p className="py-3 text-sm text-text-secondary">
|
|
208
|
+
{t('movethewheels.routes.suggest.noDrivers', 'No driver is free right now.')}
|
|
209
|
+
</p>
|
|
210
|
+
) : (
|
|
211
|
+
<ul className="divide-y">
|
|
212
|
+
{drivers.map((s, index) => (
|
|
213
|
+
<SuggestionRow
|
|
214
|
+
key={s.driver.id}
|
|
215
|
+
id={s.driver.id}
|
|
216
|
+
label={`${s.driver.firstName} ${s.driver.lastName}`.trim()}
|
|
217
|
+
reasons={s.reasons}
|
|
218
|
+
isBest={index === 0}
|
|
219
|
+
isAssigned={assignedDriverId === s.driver.id}
|
|
220
|
+
isPending={pendingId === s.driver.id}
|
|
221
|
+
disabled={pendingId !== null}
|
|
222
|
+
onAssign={() => assign(s.driver.id, { driverId: s.driver.id })}
|
|
223
|
+
t={t}
|
|
224
|
+
/>
|
|
225
|
+
))}
|
|
226
|
+
</ul>
|
|
227
|
+
)}
|
|
228
|
+
</section>
|
|
229
|
+
</>
|
|
230
|
+
)}
|
|
231
|
+
</CardContent>
|
|
232
|
+
</Card>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FleetAlertsPanel
|
|
3
|
+
*
|
|
4
|
+
* "Needs attention" list for the fleet: licences that have lapsed or are about
|
|
5
|
+
* to, and vehicles that are due or overdue for service. The rules are computed
|
|
6
|
+
* server-side (`fleetAlerts`); this is a read-side view that links each alert
|
|
7
|
+
* to the vehicle or driver that needs action.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { AlertTriangle, CheckCircle, ChevronRight } from 'lucide-react';
|
|
11
|
+
|
|
12
|
+
import { StatusBadge } from './StatusBadge';
|
|
13
|
+
import { Button, Card, CardContent, CardHeader, CardTitle, Skeleton } from './ui';
|
|
14
|
+
|
|
15
|
+
import type { FleetAlert, FleetAlertSeverity } from '../hooks/useFleetIntelligence';
|
|
16
|
+
|
|
17
|
+
interface FleetAlertsPanelProps {
|
|
18
|
+
alerts: FleetAlert[];
|
|
19
|
+
isLoading: boolean;
|
|
20
|
+
error: Error | null;
|
|
21
|
+
onRetry: () => void;
|
|
22
|
+
onOpen: (alert: FleetAlert) => void;
|
|
23
|
+
t: (key: string, fallback: string) => string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SEVERITY_COLORS: Record<FleetAlertSeverity, { bg: string; text: string }> = {
|
|
27
|
+
CRITICAL: { bg: 'bg-status-error-bg-subtle', text: 'text-status-error-text' },
|
|
28
|
+
WARNING: { bg: 'bg-status-warning-bg-subtle', text: 'text-status-warning-text' },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const plural = (count: number, unit: string): string => `${count} ${unit}${count === 1 ? '' : 's'}`;
|
|
32
|
+
|
|
33
|
+
/** Human-readable "how close / how late" for an alert, from its structured due data. */
|
|
34
|
+
export function describeDue(alert: Pick<FleetAlert, 'daysUntilDue' | 'kmUntilDue'>): string {
|
|
35
|
+
const parts: string[] = [];
|
|
36
|
+
const { daysUntilDue, kmUntilDue } = alert;
|
|
37
|
+
if (daysUntilDue !== null) {
|
|
38
|
+
if (daysUntilDue < 0) parts.push(`${plural(-daysUntilDue, 'day')} overdue`);
|
|
39
|
+
else if (daysUntilDue === 0) parts.push('due today');
|
|
40
|
+
else parts.push(`${plural(daysUntilDue, 'day')} left`);
|
|
41
|
+
}
|
|
42
|
+
if (kmUntilDue !== null) {
|
|
43
|
+
const km = Math.round(Math.abs(kmUntilDue));
|
|
44
|
+
parts.push(kmUntilDue < 0 ? `${km} km overdue` : `${km} km left`);
|
|
45
|
+
}
|
|
46
|
+
return parts.join(' · ');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function FleetAlertsPanel({
|
|
50
|
+
alerts,
|
|
51
|
+
isLoading,
|
|
52
|
+
error,
|
|
53
|
+
onRetry,
|
|
54
|
+
onOpen,
|
|
55
|
+
t,
|
|
56
|
+
}: FleetAlertsPanelProps) {
|
|
57
|
+
const title = t('movethewheels.fleet.alerts.title', 'Needs attention');
|
|
58
|
+
|
|
59
|
+
if (error && !isLoading) {
|
|
60
|
+
return (
|
|
61
|
+
<Card className="border-status-error-border">
|
|
62
|
+
<CardContent className="p-6 text-center space-y-3">
|
|
63
|
+
<p className="text-status-error-text font-medium">
|
|
64
|
+
{t('movethewheels.fleet.alerts.error', "Couldn't load fleet alerts")}
|
|
65
|
+
</p>
|
|
66
|
+
<p className="text-sm text-text-secondary">{error.message}</p>
|
|
67
|
+
<Button variant="outline" onClick={onRetry}>
|
|
68
|
+
{t('movethewheels.fleet.error.retry', 'Retry')}
|
|
69
|
+
</Button>
|
|
70
|
+
</CardContent>
|
|
71
|
+
</Card>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (isLoading) {
|
|
76
|
+
return (
|
|
77
|
+
<Card>
|
|
78
|
+
<CardHeader>
|
|
79
|
+
<CardTitle>{title}</CardTitle>
|
|
80
|
+
</CardHeader>
|
|
81
|
+
<CardContent className="space-y-3">
|
|
82
|
+
<Skeleton className="h-12 w-full" />
|
|
83
|
+
<Skeleton className="h-12 w-full" />
|
|
84
|
+
</CardContent>
|
|
85
|
+
</Card>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (alerts.length === 0) {
|
|
90
|
+
return (
|
|
91
|
+
<Card>
|
|
92
|
+
<CardHeader>
|
|
93
|
+
<CardTitle className="flex items-center gap-2">
|
|
94
|
+
<CheckCircle size={18} aria-hidden="true" className="text-status-success-text" />
|
|
95
|
+
{title}
|
|
96
|
+
</CardTitle>
|
|
97
|
+
</CardHeader>
|
|
98
|
+
<CardContent>
|
|
99
|
+
<p className="text-sm text-text-secondary">
|
|
100
|
+
{t(
|
|
101
|
+
'movethewheels.fleet.alerts.allClear',
|
|
102
|
+
'All clear — every licence is valid and no vehicle is due for service.'
|
|
103
|
+
)}
|
|
104
|
+
</p>
|
|
105
|
+
</CardContent>
|
|
106
|
+
</Card>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<Card>
|
|
112
|
+
<CardHeader>
|
|
113
|
+
<CardTitle className="flex items-center gap-2">
|
|
114
|
+
<AlertTriangle size={18} aria-hidden="true" className="text-status-warning-text" />
|
|
115
|
+
{title} ({alerts.length})
|
|
116
|
+
</CardTitle>
|
|
117
|
+
</CardHeader>
|
|
118
|
+
<CardContent className="p-0">
|
|
119
|
+
<ul className="divide-y" aria-label={title}>
|
|
120
|
+
{alerts.map((alert) => {
|
|
121
|
+
const detail = describeDue(alert);
|
|
122
|
+
return (
|
|
123
|
+
<li key={alert.id}>
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
onClick={() => onOpen(alert)}
|
|
127
|
+
className="flex w-full items-center gap-3 px-6 py-3 text-left hover:bg-bg-sunken/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-action-primary-bg"
|
|
128
|
+
>
|
|
129
|
+
<StatusBadge
|
|
130
|
+
status={alert.severity === 'CRITICAL' ? 'Critical' : 'Warning'}
|
|
131
|
+
customColors={SEVERITY_COLORS[alert.severity]}
|
|
132
|
+
size="sm"
|
|
133
|
+
showDot={false}
|
|
134
|
+
/>
|
|
135
|
+
<span className="flex-1 min-w-0">
|
|
136
|
+
<span className="block text-sm font-medium truncate">{alert.message}</span>
|
|
137
|
+
{detail && <span className="block text-xs text-text-secondary">{detail}</span>}
|
|
138
|
+
</span>
|
|
139
|
+
<ChevronRight size={16} aria-hidden="true" className="shrink-0" />
|
|
140
|
+
</button>
|
|
141
|
+
</li>
|
|
142
|
+
);
|
|
143
|
+
})}
|
|
144
|
+
</ul>
|
|
145
|
+
</CardContent>
|
|
146
|
+
</Card>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
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 } 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 { ShippingQuote, ShippingQuoteResult } from '../hooks/useShippingCarriers';
|
|
29
|
+
import { formatCurrency } from '../utils/formatters';
|
|
30
|
+
|
|
31
|
+
interface ShippingQuoteResultsProps {
|
|
32
|
+
result: ShippingQuoteResult;
|
|
33
|
+
t: (key: string, fallback: string) => string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const HIGHLIGHT = { bg: 'bg-status-success-bg-subtle', text: 'text-status-success-text' };
|
|
37
|
+
const INFO = { bg: 'bg-status-info-bg-subtle', text: 'text-status-info-text' };
|
|
38
|
+
|
|
39
|
+
function transit(quote: ShippingQuote, t: ShippingQuoteResultsProps['t']): string {
|
|
40
|
+
if (quote.estimatedDeliveryDays === null) return quote.estimatedDeliveryDate ?? '—';
|
|
41
|
+
const unit =
|
|
42
|
+
quote.estimatedDeliveryDays === 1
|
|
43
|
+
? t('movethewheels.shipping.results.day', 'day')
|
|
44
|
+
: t('movethewheels.shipping.results.days', 'days');
|
|
45
|
+
const days = `${quote.estimatedDeliveryDays} ${unit}`;
|
|
46
|
+
return quote.estimatedDeliveryDate ? `${days} · ${quote.estimatedDeliveryDate}` : days;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function modeLabel(quote: ShippingQuote, t: ShippingQuoteResultsProps['t']): string {
|
|
50
|
+
if (quote.mode === 'AIR') return t('movethewheels.shipping.results.air', 'Air');
|
|
51
|
+
if (quote.mode === 'SURFACE') return t('movethewheels.shipping.results.surface', 'Surface');
|
|
52
|
+
return '—';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function QuoteBadges({ quote, t }: { quote: ShippingQuote; t: ShippingQuoteResultsProps['t'] }) {
|
|
56
|
+
return (
|
|
57
|
+
<>
|
|
58
|
+
{quote.isCheapest && (
|
|
59
|
+
<StatusBadge
|
|
60
|
+
status={t('movethewheels.shipping.results.cheapest', 'Cheapest')}
|
|
61
|
+
customColors={HIGHLIGHT}
|
|
62
|
+
size="sm"
|
|
63
|
+
showDot={false}
|
|
64
|
+
/>
|
|
65
|
+
)}
|
|
66
|
+
{quote.isFastest && (
|
|
67
|
+
<StatusBadge
|
|
68
|
+
status={t('movethewheels.shipping.results.fastest', 'Fastest')}
|
|
69
|
+
customColors={INFO}
|
|
70
|
+
size="sm"
|
|
71
|
+
showDot={false}
|
|
72
|
+
/>
|
|
73
|
+
)}
|
|
74
|
+
{quote.isRecommended && (
|
|
75
|
+
<StatusBadge
|
|
76
|
+
status={t('movethewheels.shipping.results.recommended', 'Recommended')}
|
|
77
|
+
customColors={INFO}
|
|
78
|
+
size="sm"
|
|
79
|
+
showDot={false}
|
|
80
|
+
/>
|
|
81
|
+
)}
|
|
82
|
+
</>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function ShippingQuoteResults({ result, t }: ShippingQuoteResultsProps) {
|
|
87
|
+
const failed = result.providers.filter((provider) => !provider.ok);
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className="space-y-4">
|
|
91
|
+
{failed.map((provider) => (
|
|
92
|
+
<div
|
|
93
|
+
key={provider.provider}
|
|
94
|
+
role="alert"
|
|
95
|
+
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"
|
|
96
|
+
>
|
|
97
|
+
<AlertTriangle size={18} className="mt-0.5 shrink-0" aria-hidden="true" />
|
|
98
|
+
<span>{provider.errorMessage}</span>
|
|
99
|
+
</div>
|
|
100
|
+
))}
|
|
101
|
+
|
|
102
|
+
<Card>
|
|
103
|
+
<CardHeader>
|
|
104
|
+
<CardTitle>
|
|
105
|
+
{t('movethewheels.shipping.results.title', 'Courier options')} ({result.quotes.length})
|
|
106
|
+
</CardTitle>
|
|
107
|
+
<p className="text-sm text-text-secondary">
|
|
108
|
+
{t('movethewheels.shipping.results.chargeableWeight', 'Charged on')}{' '}
|
|
109
|
+
{result.chargeableWeightKg} kg
|
|
110
|
+
</p>
|
|
111
|
+
</CardHeader>
|
|
112
|
+
<CardContent className="p-0">
|
|
113
|
+
{result.quotes.length === 0 ? (
|
|
114
|
+
<p className="px-6 pb-6 text-sm text-text-secondary">
|
|
115
|
+
{failed.length > 0
|
|
116
|
+
? t(
|
|
117
|
+
'movethewheels.shipping.results.allFailed',
|
|
118
|
+
'No rates could be fetched — see the message above.'
|
|
119
|
+
)
|
|
120
|
+
: t(
|
|
121
|
+
'movethewheels.shipping.results.noServiceable',
|
|
122
|
+
'No courier serves this pincode pair.'
|
|
123
|
+
)}
|
|
124
|
+
</p>
|
|
125
|
+
) : (
|
|
126
|
+
<>
|
|
127
|
+
<ul
|
|
128
|
+
className="divide-y md:hidden"
|
|
129
|
+
aria-label={t('movethewheels.shipping.results.title', 'Courier options')}
|
|
130
|
+
>
|
|
131
|
+
{result.quotes.map((quote) => (
|
|
132
|
+
<li key={`${quote.provider}:${quote.courierId}`} className="space-y-2 px-4 py-4">
|
|
133
|
+
<div className="flex items-start justify-between gap-3">
|
|
134
|
+
<span className="min-w-0 font-medium">{quote.courierName}</span>
|
|
135
|
+
<span className="shrink-0 text-lg font-semibold">
|
|
136
|
+
{formatCurrency(quote.price, quote.currency)}
|
|
137
|
+
</span>
|
|
138
|
+
</div>
|
|
139
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
140
|
+
<QuoteBadges quote={quote} t={t} />
|
|
141
|
+
</div>
|
|
142
|
+
<p className="text-sm text-text-secondary">
|
|
143
|
+
{[
|
|
144
|
+
modeLabel(quote, t),
|
|
145
|
+
transit(quote, t),
|
|
146
|
+
quote.rating === null ? null : `${quote.rating.toFixed(1)} ★`,
|
|
147
|
+
]
|
|
148
|
+
.filter((part) => part !== '—' && part !== null)
|
|
149
|
+
.join(' · ')}
|
|
150
|
+
</p>
|
|
151
|
+
</li>
|
|
152
|
+
))}
|
|
153
|
+
</ul>
|
|
154
|
+
<div className="hidden md:block">
|
|
155
|
+
<Table>
|
|
156
|
+
<TableHeader>
|
|
157
|
+
<TableRow>
|
|
158
|
+
<TableHead>
|
|
159
|
+
{t('movethewheels.shipping.results.courier', 'Courier')}
|
|
160
|
+
</TableHead>
|
|
161
|
+
<TableHead>{t('movethewheels.shipping.results.mode', 'Mode')}</TableHead>
|
|
162
|
+
<TableHead>
|
|
163
|
+
{t('movethewheels.shipping.results.transit', 'Estimated delivery')}
|
|
164
|
+
</TableHead>
|
|
165
|
+
<TableHead>{t('movethewheels.shipping.results.rating', 'Rating')}</TableHead>
|
|
166
|
+
<TableHead className="text-right">
|
|
167
|
+
{t('movethewheels.shipping.results.price', 'Price')}
|
|
168
|
+
</TableHead>
|
|
169
|
+
</TableRow>
|
|
170
|
+
</TableHeader>
|
|
171
|
+
<TableBody>
|
|
172
|
+
{result.quotes.map((quote) => (
|
|
173
|
+
<TableRow key={`${quote.provider}:${quote.courierId}`}>
|
|
174
|
+
<TableCell>
|
|
175
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
176
|
+
<span className="font-medium">{quote.courierName}</span>
|
|
177
|
+
<QuoteBadges quote={quote} t={t} />
|
|
178
|
+
</div>
|
|
179
|
+
</TableCell>
|
|
180
|
+
<TableCell>{modeLabel(quote, t)}</TableCell>
|
|
181
|
+
<TableCell>{transit(quote, t)}</TableCell>
|
|
182
|
+
<TableCell>
|
|
183
|
+
{quote.rating === null ? '—' : quote.rating.toFixed(1)}
|
|
184
|
+
</TableCell>
|
|
185
|
+
<TableCell className="text-right font-medium">
|
|
186
|
+
{formatCurrency(quote.price, quote.currency)}
|
|
187
|
+
</TableCell>
|
|
188
|
+
</TableRow>
|
|
189
|
+
))}
|
|
190
|
+
</TableBody>
|
|
191
|
+
</Table>
|
|
192
|
+
</div>
|
|
193
|
+
</>
|
|
194
|
+
)}
|
|
195
|
+
</CardContent>
|
|
196
|
+
</Card>
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|