From f7e3c8fe165c37f20512c13a409a7dc6fc1f36bd Mon Sep 17 00:00:00 2001 From: Hickmeister <35031453+Hickmeister@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:24:22 +0100 Subject: [PATCH] Show destination flags and readable shipment dates Co-Authored-By: Claude Sonnet 5.5 --- app/Services/Display.php | 111 ++++++++++++++++++++++++++++ resources/views/dashboard.blade.php | 2 +- resources/views/shipping.blade.php | 6 +- tests/Unit/DisplayTest.php | 38 ++++++++++ 4 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 app/Services/Display.php create mode 100644 tests/Unit/DisplayTest.php diff --git a/app/Services/Display.php b/app/Services/Display.php new file mode 100644 index 0000000..dd559aa --- /dev/null +++ b/app/Services/Display.php @@ -0,0 +1,111 @@ + ISO 3166-1 alpha-3 to alpha-2 */ + private const ALPHA3_TO_ALPHA2 = [ + 'AFG' => 'AF', 'ALA' => 'AX', 'ALB' => 'AL', 'DZA' => 'DZ', 'ASM' => 'AS', 'AND' => 'AD', 'AGO' => 'AO', 'AIA' => 'AI', + 'ATG' => 'AG', 'ARG' => 'AR', 'ARM' => 'AM', 'ABW' => 'AW', 'AUS' => 'AU', 'AUT' => 'AT', 'AZE' => 'AZ', 'BHS' => 'BS', + 'BHR' => 'BH', 'BGD' => 'BD', 'BRB' => 'BB', 'BLR' => 'BY', 'BEL' => 'BE', 'BLZ' => 'BZ', 'BEN' => 'BJ', 'BMU' => 'BM', + 'BTN' => 'BT', 'BOL' => 'BO', 'BIH' => 'BA', 'BWA' => 'BW', 'BRA' => 'BR', 'VGB' => 'VG', 'BRN' => 'BN', 'BGR' => 'BG', + 'BFA' => 'BF', 'BDI' => 'BI', 'KHM' => 'KH', 'CMR' => 'CM', 'CAN' => 'CA', 'CPV' => 'CV', 'CYM' => 'KY', 'CAF' => 'CF', + 'TCD' => 'TD', 'CHL' => 'CL', 'CHN' => 'CN', 'COL' => 'CO', 'COM' => 'KM', 'COG' => 'CG', 'COD' => 'CD', 'COK' => 'CK', + 'CRI' => 'CR', 'CIV' => 'CI', 'HRV' => 'HR', 'CUB' => 'CU', 'CUW' => 'CW', 'CYP' => 'CY', 'CZE' => 'CZ', 'DNK' => 'DK', + 'DJI' => 'DJ', 'DMA' => 'DM', 'DOM' => 'DO', 'ECU' => 'EC', 'EGY' => 'EG', 'SLV' => 'SV', 'GNQ' => 'GQ', 'ERI' => 'ER', + 'EST' => 'EE', 'SWZ' => 'SZ', 'ETH' => 'ET', 'FRO' => 'FO', 'FJI' => 'FJ', 'FIN' => 'FI', 'FRA' => 'FR', 'GUF' => 'GF', + 'PYF' => 'PF', 'GAB' => 'GA', 'GMB' => 'GM', 'GEO' => 'GE', 'DEU' => 'DE', 'GHA' => 'GH', 'GIB' => 'GI', 'GRC' => 'GR', + 'GRL' => 'GL', 'GRD' => 'GD', 'GLP' => 'GP', 'GUM' => 'GU', 'GTM' => 'GT', 'GGY' => 'GG', 'GIN' => 'GN', 'GNB' => 'GW', + 'GUY' => 'GY', 'HTI' => 'HT', 'HND' => 'HN', 'HKG' => 'HK', 'HUN' => 'HU', 'ISL' => 'IS', 'IND' => 'IN', 'IDN' => 'ID', + 'IRN' => 'IR', 'IRQ' => 'IQ', 'IRL' => 'IE', 'IMN' => 'IM', 'ISR' => 'IL', 'ITA' => 'IT', 'JAM' => 'JM', 'JPN' => 'JP', + 'JEY' => 'JE', 'JOR' => 'JO', 'KAZ' => 'KZ', 'KEN' => 'KE', 'KIR' => 'KI', 'XKX' => 'XK', 'KWT' => 'KW', 'KGZ' => 'KG', + 'LAO' => 'LA', 'LVA' => 'LV', 'LBN' => 'LB', 'LSO' => 'LS', 'LBR' => 'LR', 'LBY' => 'LY', 'LIE' => 'LI', 'LTU' => 'LT', + 'LUX' => 'LU', 'MAC' => 'MO', 'MDG' => 'MG', 'MWI' => 'MW', 'MYS' => 'MY', 'MDV' => 'MV', 'MLI' => 'ML', 'MLT' => 'MT', + 'MHL' => 'MH', 'MTQ' => 'MQ', 'MRT' => 'MR', 'MUS' => 'MU', 'MYT' => 'YT', 'MEX' => 'MX', 'FSM' => 'FM', 'MDA' => 'MD', + 'MCO' => 'MC', 'MNG' => 'MN', 'MNE' => 'ME', 'MSR' => 'MS', 'MAR' => 'MA', 'MOZ' => 'MZ', 'MMR' => 'MM', 'NAM' => 'NA', + 'NRU' => 'NR', 'NPL' => 'NP', 'NLD' => 'NL', 'NCL' => 'NC', 'NZL' => 'NZ', 'NIC' => 'NI', 'NER' => 'NE', 'NGA' => 'NG', + 'NIU' => 'NU', 'PRK' => 'KP', 'MKD' => 'MK', 'MNP' => 'MP', 'NOR' => 'NO', 'OMN' => 'OM', 'PAK' => 'PK', 'PLW' => 'PW', + 'PSE' => 'PS', 'PAN' => 'PA', 'PNG' => 'PG', 'PRY' => 'PY', 'PER' => 'PE', 'PHL' => 'PH', 'POL' => 'PL', 'PRT' => 'PT', + 'PRI' => 'PR', 'QAT' => 'QA', 'REU' => 'RE', 'ROU' => 'RO', 'RUS' => 'RU', 'RWA' => 'RW', 'WSM' => 'WS', 'SMR' => 'SM', + 'STP' => 'ST', 'SAU' => 'SA', 'SEN' => 'SN', 'SRB' => 'RS', 'SYC' => 'SC', 'SLE' => 'SL', 'SGP' => 'SG', 'SXM' => 'SX', + 'SVK' => 'SK', 'SVN' => 'SI', 'SLB' => 'SB', 'SOM' => 'SO', 'ZAF' => 'ZA', 'KOR' => 'KR', 'SSD' => 'SS', 'ESP' => 'ES', + 'LKA' => 'LK', 'KNA' => 'KN', 'LCA' => 'LC', 'VCT' => 'VC', 'SDN' => 'SD', 'SUR' => 'SR', 'SWE' => 'SE', 'CHE' => 'CH', + 'SYR' => 'SY', 'TWN' => 'TW', 'TJK' => 'TJ', 'TZA' => 'TZ', 'THA' => 'TH', 'TLS' => 'TL', 'TGO' => 'TG', 'TON' => 'TO', + 'TTO' => 'TT', 'TUN' => 'TN', 'TUR' => 'TR', 'TKM' => 'TM', 'TCA' => 'TC', 'TUV' => 'TV', 'UGA' => 'UG', 'UKR' => 'UA', + 'ARE' => 'AE', 'GBR' => 'GB', 'USA' => 'US', 'VIR' => 'VI', 'URY' => 'UY', 'UZB' => 'UZ', 'VUT' => 'VU', 'VAT' => 'VA', + 'VEN' => 'VE', 'VNM' => 'VN', 'WLF' => 'WF', 'YEM' => 'YE', 'ZMB' => 'ZM', 'ZWE' => 'ZW', + ]; + + /** @var array */ + private const COUNTRY_ALIASES = [ + 'UK' => 'GB', 'UNITED KINGDOM' => 'GB', 'GREAT BRITAIN' => 'GB', 'ENGLAND' => 'GB', 'SCOTLAND' => 'GB', + 'WALES' => 'GB', 'NORTHERN IRELAND' => 'GB', 'UNITED STATES' => 'US', 'IRELAND' => 'IE', + ]; + + /** + * Resolve an ISO alpha-2 code from an address, accepting alpha-2, alpha-3 or common country names. + * + * @param array $address + */ + public function countryCode(array $address): ?string + { + foreach (['CountryIsoCode', 'CountryISOCode', 'CountryIso3Code', 'CountryISO3Code', 'CountryIso', 'Country'] as $field) { + $value = strtoupper(trim((string) ($address[$field] ?? ''))); + if ($value === '') { + continue; + } + if (isset(self::COUNTRY_ALIASES[$value])) { + return self::COUNTRY_ALIASES[$value]; + } + if (strlen($value) === 3 && isset(self::ALPHA3_TO_ALPHA2[$value])) { + return self::ALPHA3_TO_ALPHA2[$value]; + } + if (preg_match('/^[A-Z]{2}$/', $value) === 1) { + return $value; + } + } + + return null; + } + + /** + * Render a small flag image for the address destination, or a dash when the country is unknown. + * + * @param array $address + */ + public function flag(array $address): HtmlString + { + $code = $this->countryCode($address); + if ($code === null) { + return new HtmlString('—'); + } + $lower = strtolower($code); + + return new HtmlString(sprintf( + '%2$s flag%2$s', + $lower, + e($code), + )); + } + + /** Format an API date or date-time as a readable string, dropping the time when it is midnight. */ + public function dateTime(string|int|null $value): string + { + if ($value === null || $value === '') { + return '—'; + } + + try { + $date = is_int($value) ? Carbon::createFromTimestamp($value) : Carbon::parse($value); + } catch (Throwable) { + return (string) $value; + } + + return $date->format($date->format('H:i:s') === '00:00:00' ? 'D j M Y' : 'D j M Y, H:i'); + } +} diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index 5f8df14..62004db 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -96,7 +96,7 @@

Recent shipments

Parcel2Go · {{ $shippingStatus }}

Labels & collections →
@forelse($shipments as $shipment) -

#{{ $shipment['OrderLineId'] ?? '—' }} · {{ $shipment['Courier'] ?? 'Courier' }}

{{ $shipment['DeliveryAddress']['ContactName'] ?? 'Recipient unavailable' }}

{{ ($shipment['Tracking']['Delivered'] ?? false) ? 'Delivered' : 'In transit' }}
+

#{{ $shipment['OrderLineId'] ?? '—' }} · {{ $shipment['Courier'] ?? 'Courier' }}

{{ app(\App\Services\Display::class)->flag($shipment['DeliveryAddress'] ?? []) }}{{ $shipment['DeliveryAddress']['ContactName'] ?? 'Recipient unavailable' }}

{{ ($shipment['Tracking']['Delivered'] ?? false) ? 'Delivered' : 'In transit' }}
@empty

{{ $shippingStatus === 'Live account' ? 'No recent shipments.' : $shippingStatus }}

@endforelse
diff --git a/resources/views/shipping.blade.php b/resources/views/shipping.blade.php index aa6129b..ed6d74d 100644 --- a/resources/views/shipping.blade.php +++ b/resources/views/shipping.blade.php @@ -33,9 +33,9 @@
Sender and collection@foreach([['sender_name','Name'],['sender_email','Email'],['sender_phone','Phone'],['sender_property','Property'],['sender_street','Street'],['sender_town','Town']] as [$field,$label])@endforeach
Recipient and delivery@foreach([['recipient_name','Name'],['recipient_email','Email'],['recipient_phone','Phone'],['recipient_property','Property'],['recipient_street','Street'],['recipient_town','Town']] as [$field,$label])@endforeach
@endif

Existing shipments

Current Parcel2Go progress and labels from your account history.

- +
OrderCourierServiceProgressEstimated deliveryRecipientLabel
@forelse(($orders['Orders'] ?? []) as $order) - - @empty@endforelse + + @empty@endforelse
OrderCourierServiceProgressEstimated deliveryRecipientDestinationLabel
{{ $order['OrderLineId'] ?? '—' }}{{ $order['Courier'] ?? '—' }}{{ $order['Service'] ?? '—' }} $order['Tracking']['Delivered'] ?? false, 'bg-sky-500/10 text-sky-300' => ! ($order['Tracking']['Delivered'] ?? false)])>{{ ($order['Tracking']['Delivered'] ?? false) ? 'Delivered' : 'In transit' }}{{ $order['EstimatedDeliveryDate'] ?? '—' }}{{ $order['DeliveryAddress']['ContactName'] ?? '—' }}@if(! empty($order['Hash']) && ! empty($order['OrderLineId']))Download PDF@elseUnavailable@endif
{{ $historyAvailable ? 'No shipments in Parcel2Go account history.' : 'Shipment history is unavailable. Check the Client Credentials in Settings.' }}
{{ $order['OrderLineId'] ?? '—' }}{{ $order['Courier'] ?? '—' }}{{ $order['Service'] ?? '—' }} $order['Tracking']['Delivered'] ?? false, 'bg-sky-500/10 text-sky-300' => ! ($order['Tracking']['Delivered'] ?? false)])>{{ ($order['Tracking']['Delivered'] ?? false) ? 'Delivered' : 'In transit' }}{{ app(\App\Services\Display::class)->dateTime($order['EstimatedDeliveryDate'] ?? null) }}{{ $order['DeliveryAddress']['ContactName'] ?? '—' }}{{ app(\App\Services\Display::class)->flag($order['DeliveryAddress'] ?? []) }}@if(! empty($order['Hash']) && ! empty($order['OrderLineId']))Download PDF@elseUnavailable@endif
{{ $historyAvailable ? 'No shipments in Parcel2Go account history.' : 'Shipment history is unavailable. Check the Client Credentials in Settings.' }}
@endsection diff --git a/tests/Unit/DisplayTest.php b/tests/Unit/DisplayTest.php new file mode 100644 index 0000000..e4719bc --- /dev/null +++ b/tests/Unit/DisplayTest.php @@ -0,0 +1,38 @@ +assertSame('GB', $display->countryCode(['CountryIsoCode' => 'GBR'])); + $this->assertSame('DE', $display->countryCode(['CountryIsoCode' => 'de'])); + $this->assertSame('GB', $display->countryCode(['Country' => 'United Kingdom'])); + $this->assertNull($display->countryCode(['CountryIsoCode' => 'ZZZZ'])); + $this->assertNull($display->countryCode([])); + } + + public function test_flag_renders_image_or_dash(): void + { + $display = new Display; + + $this->assertStringContainsString('flagcdn.com/w40/fr.png', (string) $display->flag(['CountryIsoCode' => 'FRA'])); + $this->assertStringContainsString('—', (string) $display->flag([])); + } + + public function test_date_times_are_readable_and_drop_midnight(): void + { + $display = new Display; + + $this->assertSame('Thu 8 Oct 2026', $display->dateTime('2026-10-08T00:00:00')); + $this->assertSame('Thu 8 Oct 2026, 14:30', $display->dateTime('2026-10-08T14:30:00')); + $this->assertSame('—', $display->dateTime(null)); + $this->assertSame('not a date', $display->dateTime('not a date')); + } +}