Show destination flags and readable shipment dates
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 28s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Hickmeister
2026-10-11 19:24:22 +01:00
co-authored by Claude Sonnet 5.5
parent 6cc2f2c3cc
commit f7e3c8fe16
4 changed files with 153 additions and 4 deletions
+111
View File
@@ -0,0 +1,111 @@
<?php
namespace App\Services;
use Illuminate\Support\Carbon;
use Illuminate\Support\HtmlString;
use Throwable;
class Display
{
/** @var array<string, string> 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<string, string> */
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<string, mixed> $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<string, mixed> $address
*/
public function flag(array $address): HtmlString
{
$code = $this->countryCode($address);
if ($code === null) {
return new HtmlString('<span class="text-slate-500">—</span>');
}
$lower = strtolower($code);
return new HtmlString(sprintf(
'<span class="inline-flex items-center gap-2"><img src="https://flagcdn.com/w40/%1$s.png" srcset="https://flagcdn.com/w80/%1$s.png 2x" width="20" height="15" alt="%2$s flag" loading="lazy" class="rounded-sm"><span>%2$s</span></span>',
$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');
}
}
+1 -1
View File
@@ -96,7 +96,7 @@
<div class="flex items-center justify-between gap-3 border-b border-slate-800 px-5 py-4 sm:px-6"><div><h3 class="font-semibold">Recent shipments</h3><p class="mt-1 text-xs text-slate-400">Parcel2Go · {{ $shippingStatus }}</p></div><a href="{{ route('shipping') }}" class="text-sm font-semibold text-brand hover:underline">Labels & collections →</a></div>
<div class="divide-y divide-slate-800 px-5 sm:px-6">
@forelse($shipments as $shipment)
<div class="flex items-center justify-between gap-4 py-4"><div class="min-w-0"><p class="truncate font-medium">#{{ $shipment['OrderLineId'] ?? '—' }} · {{ $shipment['Courier'] ?? 'Courier' }}</p><p class="mt-1 truncate text-xs text-slate-400">{{ $shipment['DeliveryAddress']['ContactName'] ?? 'Recipient unavailable' }}</p></div><span class="shrink-0 text-xs text-slate-400">{{ ($shipment['Tracking']['Delivered'] ?? false) ? 'Delivered' : 'In transit' }}</span></div>
<div class="flex items-center justify-between gap-4 py-4"><div class="min-w-0"><p class="truncate font-medium">#{{ $shipment['OrderLineId'] ?? '—' }} · {{ $shipment['Courier'] ?? 'Courier' }}</p><p class="mt-1 truncate text-xs text-slate-400"><span class="mr-2 inline-block align-middle">{{ app(\App\Services\Display::class)->flag($shipment['DeliveryAddress'] ?? []) }}</span>{{ $shipment['DeliveryAddress']['ContactName'] ?? 'Recipient unavailable' }}</p></div><span class="shrink-0 text-xs text-slate-400">{{ ($shipment['Tracking']['Delivered'] ?? false) ? 'Delivered' : 'In transit' }}</span></div>
@empty<p class="py-8 text-sm text-slate-400">{{ $shippingStatus === 'Live account' ? 'No recent shipments.' : $shippingStatus }}</p>@endforelse
</div>
</section>
+3 -3
View File
@@ -33,9 +33,9 @@
<div class="grid gap-5 lg:grid-cols-2"><fieldset class="grid gap-3 rounded-xl border border-slate-700 p-4"><legend class="px-2 font-semibold">Sender and collection</legend>@foreach([['sender_name','Name'],['sender_email','Email'],['sender_phone','Phone'],['sender_property','Property'],['sender_street','Street'],['sender_town','Town']] as [$field,$label])<label class="grid gap-1 text-sm">{{ $label }}<input name="{{ $field }}" value="{{ old($field, session('shipping_sender_location.'.$field)) }}" required class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2"></label>@endforeach</fieldset><fieldset class="grid gap-3 rounded-xl border border-slate-700 p-4"><legend class="px-2 font-semibold">Recipient and delivery</legend>@foreach([['recipient_name','Name'],['recipient_email','Email'],['recipient_phone','Phone'],['recipient_property','Property'],['recipient_street','Street'],['recipient_town','Town']] as [$field,$label])<label class="grid gap-1 text-sm">{{ $label }}<input name="{{ $field }}" value="{{ old($field, ['recipient_name' => $selectedReceipt['name'] ?? '', 'recipient_email' => $selectedReceipt['buyer_email'] ?? '', 'recipient_property' => $selectedReceipt['first_line'] ?? '', 'recipient_street' => $selectedReceipt['second_line'] ?? '', 'recipient_town' => $selectedReceipt['city'] ?? ''][$field] ?? '') }}" required class="rounded-lg border border-slate-700 bg-slate-950 px-3 py-2"></label>@endforeach</fieldset></div><label class="grid max-w-xl gap-2 text-sm">Parcel contents<input name="contents" value="{{ old('contents') }}" required maxlength="200" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3"></label><button class="w-fit rounded-xl bg-brand px-5 py-3 font-semibold text-slate-950">Create unpaid order</button></form></section>@endif
<section class="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-900">
<div class="p-6"><h3 class="text-lg font-semibold">Existing shipments</h3><p class="mt-1 text-sm text-slate-400">Current Parcel2Go progress and labels from your account history.</p></div>
<table class="w-full min-w-[800px] text-left text-sm"><thead class="border-y border-slate-800 text-slate-400"><tr><th class="p-4">Order</th><th class="p-4">Courier</th><th class="p-4">Service</th><th class="p-4">Progress</th><th class="p-4">Estimated delivery</th><th class="p-4">Recipient</th><th class="p-4">Label</th></tr></thead><tbody class="divide-y divide-slate-800">
<table class="w-full min-w-[800px] text-left text-sm"><thead class="border-y border-slate-800 text-slate-400"><tr><th class="p-4">Order</th><th class="p-4">Courier</th><th class="p-4">Service</th><th class="p-4">Progress</th><th class="p-4">Estimated delivery</th><th class="p-4">Recipient</th><th class="p-4">Destination</th><th class="p-4">Label</th></tr></thead><tbody class="divide-y divide-slate-800">
@forelse(($orders['Orders'] ?? []) as $order)
<tr><td class="p-4">{{ $order['OrderLineId'] ?? '—' }}</td><td class="p-4">{{ $order['Courier'] ?? '—' }}</td><td class="p-4">{{ $order['Service'] ?? '—' }}</td><td class="p-4"><span @class(['rounded-full px-2 py-1 text-xs font-semibold', 'bg-emerald-500/10 text-emerald-300' => $order['Tracking']['Delivered'] ?? false, 'bg-sky-500/10 text-sky-300' => ! ($order['Tracking']['Delivered'] ?? false)])>{{ ($order['Tracking']['Delivered'] ?? false) ? 'Delivered' : 'In transit' }}</span></td><td class="p-4">{{ $order['EstimatedDeliveryDate'] ?? '—' }}</td><td class="p-4">{{ $order['DeliveryAddress']['ContactName'] ?? '—' }}</td><td class="p-4">@if(! empty($order['Hash']) && ! empty($order['OrderLineId']))<a class="font-semibold text-brand hover:underline" href="{{ route('shipping.label', $order['OrderLineId']) }}">Download PDF</a>@else<span class="text-slate-500">Unavailable</span>@endif</td></tr>
@empty<tr><td colspan="7" class="p-6 text-slate-400">{{ $historyAvailable ? 'No shipments in Parcel2Go account history.' : 'Shipment history is unavailable. Check the Client Credentials in Settings.' }}</td></tr>@endforelse
<tr><td class="p-4">{{ $order['OrderLineId'] ?? '—' }}</td><td class="p-4">{{ $order['Courier'] ?? '—' }}</td><td class="p-4">{{ $order['Service'] ?? '—' }}</td><td class="p-4"><span @class(['rounded-full px-2 py-1 text-xs font-semibold', 'bg-emerald-500/10 text-emerald-300' => $order['Tracking']['Delivered'] ?? false, 'bg-sky-500/10 text-sky-300' => ! ($order['Tracking']['Delivered'] ?? false)])>{{ ($order['Tracking']['Delivered'] ?? false) ? 'Delivered' : 'In transit' }}</span></td><td class="p-4">{{ app(\App\Services\Display::class)->dateTime($order['EstimatedDeliveryDate'] ?? null) }}</td><td class="p-4">{{ $order['DeliveryAddress']['ContactName'] ?? '—' }}</td><td class="p-4">{{ app(\App\Services\Display::class)->flag($order['DeliveryAddress'] ?? []) }}</td><td class="p-4">@if(! empty($order['Hash']) && ! empty($order['OrderLineId']))<a class="font-semibold text-brand hover:underline" href="{{ route('shipping.label', $order['OrderLineId']) }}">Download PDF</a>@else<span class="text-slate-500">Unavailable</span>@endif</td></tr>
@empty<tr><td colspan="8" class="p-6 text-slate-400">{{ $historyAvailable ? 'No shipments in Parcel2Go account history.' : 'Shipment history is unavailable. Check the Client Credentials in Settings.' }}</td></tr>@endforelse
</tbody></table>
</section>@endsection
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace Tests\Unit;
use App\Services\Display;
use Tests\TestCase;
class DisplayTest extends TestCase
{
public function test_country_code_accepts_alpha_3_alpha_2_and_names(): void
{
$display = new Display;
$this->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'));
}
}