diff --git a/modules/connectors/fedex/karrio/mappers/fedex/proxy.py b/modules/connectors/fedex/karrio/mappers/fedex/proxy.py index 3176806f32..4d43fb0020 100644 --- a/modules/connectors/fedex/karrio/mappers/fedex/proxy.py +++ b/modules/connectors/fedex/karrio/mappers/fedex/proxy.py @@ -1,9 +1,13 @@ +import typing import urllib.parse import karrio.lib as lib import karrio.api.proxy as proxy import karrio.providers.fedex.utils as provider_utils import karrio.mappers.fedex.settings as provider_settings +import karrio.schemas.fedex.tracking_document_request as fedex +import logging +logger = logging.getLogger(__name__) class Proxy(proxy.Proxy): settings: provider_settings.Settings @@ -142,3 +146,62 @@ def cancel_pickup(self, request: lib.Serializable) -> lib.Deserializable[str]: ) return lib.Deserializable(response, lib.to_dict, request.ctx) + + def get_proof_of_delivery(self, tracking_number: str) -> typing.Optional[str]: + import karrio.providers.fedex.error as error + + # Construct the request + request = fedex.TrackingDocumentRequestType( + trackDocumentSpecification=[ + fedex.TrackDocumentSpecificationType( + trackingNumberInfo=fedex.TrackingNumberInfoType( + trackingNumber=tracking_number + ) + ) + ], + trackDocumentDetail=fedex.TrackDocumentDetailType( + documentType="SIGNATURE_PROOF_OF_DELIVERY", + documentFormat="PNG", + ), + ) + + try: + # Send the request + response = lib.to_dict( + lib.request( + url=f"{self.settings.server_url}/track/v1/trackingdocuments", + data=lib.to_json(request), + method="POST", + headers={ + "x-locale": "en_US", + "content-type": "application/json", + "authorization": f"Bearer {self.settings.track_access_token}", + }, + decoder=provider_utils.parse_response, + on_error=self.log_request_error, # Direct function reference + ) + ) + messages = error.parse_error_response(response, self.settings) + if any(messages): + logger.error(f"FedEx SPOD Error for tracking number {tracking_number}: {messages}") + return None + + # Check if the documents are present in the response + documents = response.get("output", {}).get("documents") + if not documents: + logger.error(f"No POD documents found in the response for tracking number {tracking_number}") + return None + + # Convert documents to base64 + return lib.failsafe(lambda: lib.bundle_base64(documents, format="PNG")) + + except Exception as e: + # Catch any other exceptions and log the details + logger.error(f"An error occurred while fetching POD for tracking number {tracking_number}: {e}") + return None + + def log_request_error(self, response_body): + # Custom handler for logging errors during the request + error_content = response_body.read().decode() + logger.error(f"FedEx API Request Error: {error_content}") + return provider_utils.parse_response(error_content) diff --git a/modules/connectors/fedex/karrio/providers/fedex/tracking.py b/modules/connectors/fedex/karrio/providers/fedex/tracking.py index 10fe5afebb..7da986fb82 100644 --- a/modules/connectors/fedex/karrio/providers/fedex/tracking.py +++ b/modules/connectors/fedex/karrio/providers/fedex/tracking.py @@ -6,6 +6,8 @@ import karrio.providers.fedex.error as provider_error import karrio.providers.fedex.utils as provider_utils import karrio.providers.fedex.units as provider_units +import karrio.mappers.fedex.proxy as proxy + DATETIME_FORMATS = ["%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S"] @@ -68,13 +70,21 @@ def _extract_details( provider_units.TrackingStatus.in_transit.name, ) delivered = status == "delivered" + # Create a proxy instance with settings + proxy_instance = proxy.Proxy(settings=settings) + img = lib.failsafe( lambda: ( - provider_utils.get_proof_of_delivery(package.trackingNumber, settings) + proxy_instance.get_proof_of_delivery(package.trackingNumber) if delivered else None ) ) + # if img: + # logger.info(f"Successfully retrieved SPOD image for {package.trackingNumber}") + # else: + # logger.warning(f"No SPOD image available for {package.trackingNumber}") + return models.TrackingDetails( carrier_name=settings.carrier_name, @@ -122,7 +132,13 @@ def _extract_details( shipment_origin_country=lib.failsafe( lambda: detail.originLocation.locationContactAndAddress.address.countryCode ), - signed_by=lib.failsafe(lambda: detail.deliveryDetails.signedByName), + signed_by=lib.failsafe( + lambda: ( + detail.deliveryDetails.signedByName + if detail.deliveryDetails.signedByName + else (detail.deliveryDetails.receivedByName) + ) + ), ), images=lib.identity(models.Images(signature_image=img) if img else None), estimated_delivery=estimated_delivery, diff --git a/modules/connectors/fedex/karrio/providers/fedex/utils.py b/modules/connectors/fedex/karrio/providers/fedex/utils.py index b14f9ae85c..9034f22891 100644 --- a/modules/connectors/fedex/karrio/providers/fedex/utils.py +++ b/modules/connectors/fedex/karrio/providers/fedex/utils.py @@ -1,11 +1,12 @@ -import karrio.schemas.fedex.tracking_document_request as fedex import gzip import datetime import urllib.parse import karrio.lib as lib import karrio.core as core import karrio.core.errors as errors +import logging +logger = logging.getLogger(__name__) class Settings(core.Settings): """FedEx connection settings.""" @@ -142,42 +143,6 @@ def login(settings: Settings, client_id: str = None, client_secret: str = None): return {**response, "expiry": lib.fdatetime(expiry)} -def get_proof_of_delivery(tracking_number: str, settings: Settings): - import karrio.providers.fedex.error as error - - request = fedex.TrackingDocumentRequestType( - trackDocumentSpecification=[ - fedex.TrackDocumentSpecificationType( - trackingNumberInfo=fedex.TrackingNumberInfoType( - trackingNumber=tracking_number - ) - ) - ], - trackDocumentDetail=fedex.TrackDocumentDetailType( - documentType="SIGNATURE_PROOF_OF_DELIVERY", - documentFormat="PNG", - ), - ) - response = lib.to_dict( - lib.request( - url=f"{settings.server_url}/track/v1/trackingdocuments", - data=lib.to_json(request), - method="POST", - decoder=parse_response, - on_error=lambda b: parse_response(b.read()), - ) - ) - - messages = error.parse_error_response(response, settings) - - if any(messages): - return None - - return lib.failsafe( - lambda: lib.bundle_base64(response["output"]["documents"], format="PNG") - ) - - def parse_response(binary_string): content = lib.failsafe(lambda: gzip.decompress(binary_string)) or binary_string return lib.decode(content) diff --git a/modules/connectors/fedex/tests/fedex/test_tracking.py b/modules/connectors/fedex/tests/fedex/test_tracking.py index 7a37212e36..65d377a775 100644 --- a/modules/connectors/fedex/tests/fedex/test_tracking.py +++ b/modules/connectors/fedex/tests/fedex/test_tracking.py @@ -107,7 +107,7 @@ def test_parse_inconsistent_datetime_response(self): "shipment_origin_postal_code": "98101", "shipment_origin_country": "US", "shipment_service": "FedEx Freight Economy.", - "signed_by": "Reciever", + "signed_by": "Reciever", # How do I do a test for the 'fall over' when we set signed_by to recievedBy instead of signedBy? }, "status": "in_transit", "tracking_number": "123456789012", diff --git a/packages/core/components/TrackingEvents.tsx b/packages/core/components/TrackingEvents.tsx new file mode 100644 index 0000000000..524be8ccad --- /dev/null +++ b/packages/core/components/TrackingEvents.tsx @@ -0,0 +1,58 @@ +// components/TrackingEvents.tsx +import { TrackingEvent, TrackingStatus } from "@karrio/types/rest/api"; +import { formatDayDate } from "@karrio/lib"; +import { TrackerType } from "@karrio/types"; + +type DayEvents = { [k: string]: TrackingEvent[] }; + +const computeEvents = (tracker: TrackingStatus): DayEvents => { + const days: DayEvents = {}; + + (tracker?.events || []).forEach((event: TrackingEvent) => { + const daydate = formatDayDate(event.date as string); + + if (!days[daydate]) { + days[daydate] = []; + } + + days[daydate].push(event); + }); + + return days; +}; + + +const TrackingEvents: React.FC<{ tracker: TrackerType }> = ({ tracker }) => { + const eventsByDay = computeEvents(tracker as TrackingStatus); + + return ( +
+ +
+ ); +}; + +export default TrackingEvents; diff --git a/packages/core/components/TrackingHeader.tsx b/packages/core/components/TrackingHeader.tsx new file mode 100644 index 0000000000..3fa9668004 --- /dev/null +++ b/packages/core/components/TrackingHeader.tsx @@ -0,0 +1,142 @@ +// components/TrackingHeader.tsx +"use client"; + +import { useState } from "react"; +import { TrackerStatusEnum, TrackerType } from "@karrio/types"; +import { formatDayDate, formatRef, isNone } from "@karrio/lib"; +import { CarrierImage } from "@karrio/ui/components/carrier-image"; + +const TrackingHeader: React.FC<{ tracker: TrackerType }> = ({ tracker }) => { + const [isExpanded, setIsExpanded] = useState(false); // State to control expansion + + const computeColor = (tracker: TrackerType) => { + if (tracker?.delivered) return "has-background-success"; + else if (tracker?.status === TrackerStatusEnum.pending.toString()) + return "has-background-grey-dark"; + else if ( + [ + TrackerStatusEnum.on_hold.toString(), + TrackerStatusEnum.delivery_delayed.toString(), + ].includes(tracker?.status as string) + ) + return "has-background-warning"; + else if ( + [TrackerStatusEnum.unknown.toString()].includes( + tracker?.status as string, + ) + ) + return "has-background-grey"; + else if ( + [TrackerStatusEnum.delivery_failed.toString()].includes( + tracker?.status as string, + ) + ) + return "has-background-danger"; + else return "has-background-info"; + }; + + const computeStatus = (tracker: TrackerType) => { + if (tracker?.delivered) return "Delivered"; + else if (tracker?.status === TrackerStatusEnum.pending.toString()) + return "Pending"; + else if ( + [ + TrackerStatusEnum.on_hold.toString(), + TrackerStatusEnum.delivery_delayed.toString(), + TrackerStatusEnum.ready_for_pickup.toString(), + TrackerStatusEnum.unknown.toString(), + TrackerStatusEnum.delivery_failed.toString(), + ].includes(tracker?.status as string) + ) + return formatRef(tracker!.status as string); + else return "In-Transit"; + }; + + const prettifyKey = (key: string) => { + // Convert snake_case to "Pretty Case" + return key + .replace(/_/g, " ") // Replace underscores with spaces + .replace(/\b\w/g, (char) => char.toUpperCase()); // Capitalize each word + }; + + const customRender = (key: string, value: any) => { + switch (key) { + case "carrier_tracking_link": + return ( + + {String(value)} + + ); + // Add more cases as needed for custom rendering of specific fields + default: + return {String(value)}; + } + }; + + return ( + <> +
+ +
+ +

+ Tracking ID {tracker.tracking_number} +

+ + {!isNone(tracker?.estimated_delivery) && ( +

+ {tracker?.delivered ? "Delivered" : "Estimated Delivery"}{" "} + + {formatDayDate(tracker!.estimated_delivery as string)} + +

+ )} + + {!isNone(tracker?.info) && ( + <> +
+ {/* Accessible button to toggle section */} + + + {/* Conditionally render additional info */} + {isExpanded && ( +
+ {Object.entries(tracker.info || {}) + .filter(([_, value]) => value != null) // Exclude null or undefined values + .map(([key, value], index) => ( +
+ {prettifyKey(key)}: {" "} + {customRender(key, value)} +
+ ))} +
+ )} + + )} + +

+ {computeStatus(tracker as TrackerType)} +

+ + ); +}; + +export default TrackingHeader; diff --git a/packages/core/components/TrackingMessages.tsx b/packages/core/components/TrackingMessages.tsx new file mode 100644 index 0000000000..a6cc491bfe --- /dev/null +++ b/packages/core/components/TrackingMessages.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { TrackerType } from "@karrio/types"; + +interface TrackingMessagesProps { + messages: (TrackerType["messages"] extends (infer U)[] ? U : never)[]; // Allow flexibility for undefined/null +} + +const TrackingMessages: React.FC = ({ messages }) => { + if (!messages || messages.length === 0) return null; + + return ( +
+ {messages.map((msg, index) => ( +

+ {msg?.message ?? "No message available"}{" "} + {/* Handle undefined/null message */} +

+ ))} +
+ ); +}; + +export default TrackingMessages; diff --git a/packages/core/components/tracking-preview.tsx b/packages/core/components/tracking-preview.tsx index e23c156ba2..7cddd30630 100644 --- a/packages/core/components/tracking-preview.tsx +++ b/packages/core/components/tracking-preview.tsx @@ -1,20 +1,18 @@ import { formatAddressRegion, - formatDayDate, formatRef, isNone, } from "@karrio/lib"; import { - TrackerStatusEnum, TrackerType, - TrackingEventType, } from "@karrio/types"; -import { CarrierImage } from "@karrio/ui/components/carrier-image"; import { AppLink } from "@karrio/ui/components/app-link"; import React, { useRef, useState } from "react"; import { useLocation } from "@karrio/hooks/location"; +import TrackingHeader from "./TrackingHeader"; +import TrackingEvents from "./TrackingEvents"; +import TrackingMessages from "./TrackingMessages"; -type DayEvents = { [k: string]: TrackingEventType[] }; type TrackingPreviewContextType = { previewTracker: (tracker: TrackerType) => void; }; @@ -60,54 +58,6 @@ export const TrackingPreview: React.FC = ({ document.execCommand("copy"); document.body.removeChild(input); }; - const computeColor = (tracker: TrackerType) => { - if (tracker?.delivered) return "has-background-success"; - else if (tracker?.status === TrackerStatusEnum.pending.toString()) - return "has-background-grey-dark"; - else if ( - [ - TrackerStatusEnum.on_hold.toString(), - TrackerStatusEnum.delivery_delayed.toString(), - ].includes(tracker?.status as string) - ) - return "has-background-warning"; - else if ( - [TrackerStatusEnum.unknown.toString()].includes(tracker?.status as string) - ) - return "has-background-grey"; - else if ( - [TrackerStatusEnum.delivery_failed.toString()].includes( - tracker?.status as string, - ) - ) - return "has-background-danger"; - else return "has-background-info"; - }; - const computeStatus = (tracker: TrackerType) => { - if (tracker?.delivered) return "Delivered"; - else if (tracker?.status === TrackerStatusEnum.pending.toString()) - return "Pending"; - else if ( - [ - TrackerStatusEnum.on_hold.toString(), - TrackerStatusEnum.delivery_delayed.toString(), - TrackerStatusEnum.ready_for_pickup.toString(), - TrackerStatusEnum.unknown.toString(), - TrackerStatusEnum.delivery_failed.toString(), - ].includes(tracker?.status as string) - ) - return formatRef(tracker!.status as string); - else return "In-Transit"; - }; - const computeEvents = (tracker: TrackerType): DayEvents => { - return (tracker?.events || []).reduce( - (days: any, event: TrackingEventType) => { - const daydate = formatDayDate(event.date as string); - return { ...days, [daydate]: [...(days[daydate] || []), event] }; - }, - {} as DayEvents, - ); - }; return ( <> @@ -122,42 +72,7 @@ export const TrackingPreview: React.FC = ({ {!isNone(tracker) && (
-
- -
- -

- Tracking ID{" "} - {tracker?.tracking_number} -

- - {!isNone(tracker?.estimated_delivery) && ( -

- - {tracker?.delivered ? "Delivered" : "Estimated Delivery"} - {" "} - - {formatDayDate(tracker!.estimated_delivery as string)} - -

- )} - -

- {computeStatus(tracker as TrackerType)} -

+
@@ -165,45 +80,11 @@ export const TrackingPreview: React.FC = ({ className="my-3 pl-3" style={{ maxHeight: "40vh", overflowY: "scroll" }} > - +
- {(tracker?.messages || []).length > 0 && ( -
-

- {(tracker?.messages || [{}])[0].message} -

-
- )} - + + {!isNone(tracker?.shipment) && ( <>
diff --git a/packages/core/modules/Trackers/index.tsx b/packages/core/modules/Trackers/index.tsx index 93a8fb1ee8..290b3eb4f9 100644 --- a/packages/core/modules/Trackers/index.tsx +++ b/packages/core/modules/Trackers/index.tsx @@ -24,7 +24,7 @@ import { dynamicMetadata } from "@karrio/core/components/metadata"; import { StatusBadge } from "@karrio/ui/components/status-badge"; import { useLoader } from "@karrio/ui/components/loader"; import { Spinner } from "@karrio/ui/components/spinner"; -import { TrackingEvent } from "@karrio/types/rest/api"; +import { TrackingEvent, TrackingInfo } from "@karrio/types/rest/api"; import React, { useContext, useEffect } from "react"; import { useSearchParams } from "next/navigation"; @@ -249,22 +249,25 @@ export default function TrackersPage(pageProps: any) { /> - - {isNoneOrEmpty(tracker?.events) + {(() => { + const formattedDescription = isNoneOrEmpty( + tracker?.events, + ) ? "" : formatEventDescription( (tracker?.events as TrackingEvent[])[0], - )} - + tracker?.info as TrackingInfo, + ); + + return ( + + {formattedDescription} + + ); + })()}

@@ -281,7 +284,7 @@ export default function TrackersPage(pageProps: any) { onClick={(e) => { e.stopPropagation(); confirmDeletion({ - label: "Delet Shipment Tracker", + label: "Delete Shipment Tracker", identifier: tracker.id as string, onConfirm: remove(tracker.id), }); @@ -351,8 +354,22 @@ export default function TrackersPage(pageProps: any) { ); } -function formatEventDescription(last_event?: TrackingEvent): string { - return last_event?.description || ""; +function formatEventDescription( + last_event?: TrackingEvent, + tracking_info?: TrackingInfo, +): string { + if (!last_event) { + return ""; + } + + const { description } = last_event; + const { signed_by } = tracking_info || {}; + + if (description === "Delivered" && signed_by) { + return `${description} (Signed by: ${signed_by})`; + } + + return description || ""; } function formatEventDate(last_event?: TrackingEvent): string { diff --git a/packages/core/modules/Trackers/tracking-page.tsx b/packages/core/modules/Trackers/tracking-page.tsx index d607b6ccbd..13adcc88fb 100644 --- a/packages/core/modules/Trackers/tracking-page.tsx +++ b/packages/core/modules/Trackers/tracking-page.tsx @@ -1,16 +1,15 @@ -import { TrackingEvent, TrackingStatus } from "@karrio/types/rest/api"; -import { formatDayDate, isNone, KARRIO_API, url$ } from "@karrio/lib"; +import { isNone, KARRIO_API, url$ } from "@karrio/lib"; import { dynamicMetadata } from "@karrio/core/components/metadata"; -import { CarrierImage } from "@karrio/ui/components/carrier-image"; -import { Collection, KarrioClient } from "@karrio/types"; +import { Collection, KarrioClient, TrackerType } from "@karrio/types"; import { loadMetadata } from "@karrio/core/context/main"; import Link from "next/link"; import React from "react"; +import TrackingHeader from "@karrio/core/components/TrackingHeader"; +import TrackingEvents from "@karrio/core/components/TrackingEvents"; +import TrackingMessages from "@karrio/core/components/TrackingMessages"; export const generateMetadata = dynamicMetadata("Tracking"); -type DayEvents = { [k: string]: TrackingEvent[] }; - export default async function Page({ params }: { params: Collection }) { const id = params?.id as string; const { metadata } = await loadMetadata(); @@ -27,13 +26,15 @@ export default async function Page({ params }: { params: Collection }) { message: `No Tracker ID nor Tracking Number found for ${id}`, }; }); - - const computeEvents = (tracker: TrackingStatus): DayEvents => { - return (tracker?.events || []).reduce((days, event: TrackingEvent) => { - const daydate = formatDayDate(event.date as string); - return { ...days, [daydate]: [...(days[daydate] || []), event] }; - }, {} as DayEvents); - }; + // Normalize messages to replace undefined with null for TypeScript compatibility + const normalizedMessages = (tracker?.messages || []).map((message) => ({ + ...message, + carrier_name: message.carrier_name ?? null, // Convert undefined to null + carrier_id: message.carrier_id ?? null, // Convert undefined to null + message: message.message ?? null, + code: message.code ?? null, + details: message.details ?? null, + })); return ( <> @@ -51,91 +52,20 @@ export default async function Page({ params }: { params: Collection }) { <>

-
- -
- -

- Tracking ID{" "} - {tracker?.tracking_number} -

- - {!isNone(tracker?.estimated_delivery) && ( -

- - {tracker?.delivered - ? "Delivered" - : "Estimated Delivery"} - {" "} - - {formatDayDate(tracker!.estimated_delivery as string)} - -

- )} +
-
- {tracker?.status === "delivered" && ( -

- Delivered -

- )} - - {tracker?.status === "in_transit" && ( -

- In-Transit -

- )} - - {tracker?.status !== "delivered" && - tracker?.status !== "in_transit" && ( -

- Pending -

- )} -
+

-
- -
+ )} + + {!isNone(message) && (
diff --git a/packages/types/rest/api.ts b/packages/types/rest/api.ts index 4d4529fc23..75667ce9cd 100644 --- a/packages/types/rest/api.ts +++ b/packages/types/rest/api.ts @@ -8750,13 +8750,13 @@ export interface TrackingStatus { */ 'messages'?: Array; /** - * The shipment invoice URL + * The photo proof of delivery image URL * @type {string} * @memberof TrackingStatus */ 'delivery_image_url'?: string | null; /** - * The shipment invoice URL + * The the signature proof of delivery image URL * @type {string} * @memberof TrackingStatus */ diff --git a/packages/ui/modals/confirm-modal.tsx b/packages/ui/modals/confirm-modal.tsx index 571c17381f..1c4fc7f2ba 100644 --- a/packages/ui/modals/confirm-modal.tsx +++ b/packages/ui/modals/confirm-modal.tsx @@ -40,6 +40,7 @@ export const ConfirmModal: React.FC = ({ children }) => { try { await operation?.onConfirm(); notify({ + // TODO check grammar here type: NotificationType.success, message: `${operation?.label} successfully!...` }); setTimeout(() => { close(); }, 2000);