Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@
"laposte": "La Poste",
"colissimo": "Colissimo",
"chronopost": "Chronopost",
"mondialrelay": "Mondial Relay",
"speedpak": "SpeedPAK",
"usps": "USPS",
"ups": "UPS",
"fedex": "FedEx",
Expand Down
2 changes: 2 additions & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,8 @@
"laposte": "La Poste",
"colissimo": "Colissimo",
"chronopost": "Chronopost",
"mondialrelay": "Mondial Relay",
"speedpak": "SpeedPAK",
"usps": "USPS",
"ups": "UPS",
"fedex": "FedEx",
Expand Down
2 changes: 1 addition & 1 deletion src/components/shipments/shipment-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function ShipmentCard({
const { locale } = useI18n();
const protection = getShipmentProtection(shipment);
const trackingUrl = shipment.trackingNumber
? buildTrackingUrl(shipment.trackingNumber, shipment.carrier)
? buildTrackingUrl(shipment.trackingNumber, shipment.carrier, locale)
: null;
const platformOrderUrl = buildPlatformOrderUrl(
shipment.platform,
Expand Down
46 changes: 44 additions & 2 deletions src/lib/shipment-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,57 @@ describe("detectCarrier", () => {
it("detects La Poste international format", () => {
expect(detectCarrier("6A12345678901")).toBe("laposte");
});

it("detects La Poste S10 lettre suivie / Colissimo", () => {
expect(detectCarrier("RV123456789FR")).toBe("laposte");
});

it("detects La Poste S10 with alphanumeric prefix (6A…FR)", () => {
expect(detectCarrier("6A123456789FR")).toBe("laposte");
});

it("detects Mondial Relay 8-digit expedition numbers", () => {
expect(detectCarrier("12345678")).toBe("mondialrelay");
});

it("detects SpeedPAK / Orange Connex S10 from China", () => {
expect(detectCarrier("LK123456789CN")).toBe("speedpak");
});

it("detects SpeedPAK Standard (ES… 28 chars)", () => {
expect(detectCarrier("ES10024287564060001010001D0N")).toBe("speedpak");
});

it("detects SpeedPAK Economy (EE… 28 chars)", () => {
expect(detectCarrier("EE1000080117450AB01010001N0N")).toBe("speedpak");
});
});

describe("buildTrackingUrl", () => {
it("builds La Poste URL for French carriers", () => {
expect(buildTrackingUrl("6A12345678901")).toContain("laposte.fr");
});

it("falls back to 17track for unknown carriers", () => {
expect(buildTrackingUrl("XYZ123")).toContain("17track.net");
it("builds Mondial Relay URL", () => {
expect(buildTrackingUrl("12345678")).toContain("mondialrelay.fr");
});

it("builds 17track URL for SpeedPAK / Orange Connex", () => {
expect(buildTrackingUrl("LK123456789CN", null, "fr")).toContain(
"17track.net"
);
});

it("builds 17track URL for official SpeedPAK ES numbers", () => {
expect(
buildTrackingUrl("ES10024287564060001010001D0N", null, "fr")
).toContain("17track.net");
});

it("falls back to ParcelsApp for unknown carriers", () => {
expect(buildTrackingUrl("XYZ123", null, "en")).toContain(
"parcelsapp.com/en/tracking/"
);
});
});

Expand Down
78 changes: 73 additions & 5 deletions src/lib/shipment-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,36 +251,98 @@ export type DetectedCarrier =
| "laposte"
| "colissimo"
| "chronopost"
| "mondialrelay"
| "speedpak"
| "usps"
| "ups"
| "fedex"
| "dhl"
| "unknown";

/** Préfixes SpeedPAK / Orange Connex (eBay Chine → Europe). */
const SPEEDPAK_PREFIXES = [
// Format officiel Orange Connex (28 car. : ES/EE/EX/EM + 26).
"ES",
"EE",
"EX",
"EM",
// Anciens / variantes S10 Chine parfois vues sur eBay.
"LK",
"LM",
"LX",
"LY",
"LZ",
"UC",
"JD",
"SF",
] as const;

export function detectCarrier(trackingNumber: string): DetectedCarrier {
const normalized = trackingNumber.trim().toUpperCase().replace(/\s+/g, "");
if (!normalized) return "unknown";

// SpeedPAK / Orange Connex — avant les heuristiques génériques (sinon → unknown → ParcelsApp).
if (/^(ES|EE|EX|EM)[A-Z0-9]{20,30}$/.test(normalized)) {
return "speedpak";
}

if (/^1Z[A-Z0-9]{16}$/.test(normalized)) return "ups";
if (/^\d{20,22}$/.test(normalized) || /^9\d{21,}$/.test(normalized)) {
return "usps";
}
if (/^\d{12,15}$/.test(normalized) && normalized.startsWith("3")) {
return "fedex";
}
if (/^\d{10,11}$/.test(normalized)) return "dhl";
if (/^[A-Z]{2}\d{9}[A-Z]{2}$/.test(normalized)) return "laposte";

// Mondial Relay : n° d'expédition 8 chiffres (format classique).
if (/^\d{8}$/.test(normalized)) return "mondialrelay";

// DHL Express : 10 chiffres — 11 chiffres trop ambigus (souvent MR / autres).
if (/^\d{10}$/.test(normalized)) return "dhl";

// La Poste / Colissimo / Lettre suivie (S10 UPU, préfixe parfois chiffre+lettre ex. 6A…FR).
if (/^[A-Z0-9]{2}\d{9}[A-Z]{2}$/.test(normalized)) {
const destination = normalized.slice(-2);
// Variantes S10 Chine parfois routées SpeedPAK.
if (
destination === "CN" &&
SPEEDPAK_PREFIXES.some((prefix) => normalized.startsWith(prefix))
) {
return "speedpak";
}
return "laposte";
}

if (/^[A-Z0-9]{2}\d{9,11}$/.test(normalized)) return "laposte";
if (/^\d{13}$/.test(normalized)) return "colissimo";

// Autres formats SpeedPAK / Orange Connex (longueur variable).
if (
/^[A-Z0-9]{12,32}$/.test(normalized) &&
SPEEDPAK_PREFIXES.some((prefix) => normalized.startsWith(prefix))
) {
return "speedpak";
}

return "unknown";
}

export function buildTrackingUrl(
function buildParcelsAppUrl(
trackingNumber: string,
carrier?: string | null
locale: "fr" | "en" = "fr"
): string {
const code = encodeURIComponent(trackingNumber.trim());
const lang = locale === "fr" ? "fr" : "en";
return `https://parcelsapp.com/${lang}/tracking/${code}`;
}

export function buildTrackingUrl(
trackingNumber: string,
carrier?: string | null,
locale: "fr" | "en" = "fr"
): string {
const trimmed = trackingNumber.trim();
const code = encodeURIComponent(trimmed);
const detected =
carrier && carrier !== "unknown"
? (carrier as DetectedCarrier)
Expand All @@ -291,6 +353,11 @@ export function buildTrackingUrl(
case "colissimo":
case "chronopost":
return `https://www.laposte.fr/outils/suivre-vos-envois?code=${code}`;
case "mondialrelay":
return `https://www.mondialrelay.fr/suivi-de-colis/?numeroExpedition=${code}`;
case "speedpak":
// SpeedPAK / Orange Connex (eBay Chine) — suivi via 17track.
return `https://t.17track.net/en#nums=${code}`;
case "usps":
return `https://tools.usps.com/go/TrackConfirmAction?tLabels=${code}`;
case "ups":
Expand All @@ -300,7 +367,8 @@ export function buildTrackingUrl(
case "dhl":
return `https://www.dhl.com/fr-fr/home/tracking.html?tracking-id=${code}`;
default:
return `https://t.17track.net/en#nums=${code}`;
// Agrégateur multi-transporteurs (MR, Colissimo, Lettre suivie…).
return buildParcelsAppUrl(trimmed, locale);
}
}

Expand Down