added countries support expanded for ISO 3166 standard
This commit is contained in:
+85
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Add ISO 3166 governance metadata and ISO 3166-2 subdivision support.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('countries', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('countries', 'iso_status')) {
|
||||
$table->string('iso_status', 32)->default('official')->index()->after('is_active')->comment('ISO 3166-1 lifecycle status: official, reserved, transitional, deleted, user_assigned, exceptionally_reserved.');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('countries', 'independent')) {
|
||||
$table->boolean('independent')->nullable()->after('iso_status')->comment('Whether the ISO 3166-1 entry represents an independent state where known.');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('countries', 'region')) {
|
||||
$table->string('region')->nullable()->index()->after('independent')->comment('UN M49-style broad region for filtering and reporting.');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('countries', 'subregion')) {
|
||||
$table->string('subregion')->nullable()->index()->after('region')->comment('UN M49-style subregion for filtering and reporting.');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('countries', 'source')) {
|
||||
$table->string('source')->nullable()->after('last_synced_at')->comment('Reference data source used for the last sync.');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('countries', 'source_version')) {
|
||||
$table->string('source_version')->nullable()->after('source')->comment('Reference data version/release identifier.');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('countries', 'metadata')) {
|
||||
$table->json('metadata')->nullable()->after('source_version')->comment('Additional non-authoritative metadata, never used as the primary ISO identity.');
|
||||
}
|
||||
});
|
||||
|
||||
if (! Schema::hasTable('country_subdivisions')) {
|
||||
Schema::create('country_subdivisions', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('country_id')->constrained('countries')->cascadeOnDelete();
|
||||
$table->char('country_alpha2', 2)->index()->comment('Parent ISO 3166-1 alpha-2 code, duplicated for fast lookup and imports.');
|
||||
$table->string('code', 16)->unique()->comment('Full ISO 3166-2 subdivision code, for example GB-ENG or US-CA.');
|
||||
$table->string('subdivision_code', 12)->comment('Subdivision part after the country prefix, for example ENG or CA.');
|
||||
$table->string('name')->comment('ISO 3166-2 subdivision name.');
|
||||
$table->string('local_name')->nullable()->comment('Optional local-language display name.');
|
||||
$table->string('type')->nullable()->index()->comment('Subdivision type, for example country, province, state, territory, council area.');
|
||||
$table->boolean('is_active')->default(true)->index()->comment('Whether this subdivision is currently available in business workflows.');
|
||||
$table->string('iso_status', 32)->default('official')->index()->comment('ISO 3166-2 lifecycle status.');
|
||||
$table->unsignedInteger('sort_order')->default(0)->index();
|
||||
$table->timestamp('last_synced_at')->nullable();
|
||||
$table->string('source')->nullable();
|
||||
$table->string('source_version')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['country_id', 'subdivision_code']);
|
||||
$table->index(['country_alpha2', 'is_active', 'sort_order', 'name']);
|
||||
$table->index(['type', 'is_active']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the ISO 3166 support additions.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('country_subdivisions');
|
||||
|
||||
Schema::table('countries', function (Blueprint $table): void {
|
||||
foreach (['metadata', 'source_version', 'source', 'subregion', 'region', 'independent', 'iso_status'] as $column) {
|
||||
if (Schema::hasColumn('countries', $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -6,8 +6,14 @@ use Illuminate\Database\Seeder;
|
||||
|
||||
class CountriesDatabaseSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Seed ISO 3166 country reference data.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$this->call(IsoCountriesSeeder::class);
|
||||
$this->call([
|
||||
IsoCountriesSeeder::class,
|
||||
IsoCountrySubdivisionsSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +270,9 @@ class IsoCountriesSeeder extends Seeder
|
||||
'is_active' => true,
|
||||
'sort_order' => $index + 1,
|
||||
'last_synced_at' => $now,
|
||||
'iso_status' => 'official',
|
||||
'source' => 'bundled-iso-3166-1',
|
||||
'source_version' => '2026-06-15',
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Countries\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Modules\Countries\Models\Country;
|
||||
use Modules\Countries\Models\CountrySubdivision;
|
||||
|
||||
class IsoCountrySubdivisionsSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Seed high-value ISO 3166-2 subdivisions used by UK/US business workflows.
|
||||
*
|
||||
* This module is structured to import the full ISO 3166-2 data set from an approved licensed source.
|
||||
* The bundled seed keeps the module useful out of the box without pretending to be the complete ISO 3166-2 catalogue.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$now = Carbon::now();
|
||||
$rows = [
|
||||
['country' => 'GB', 'code' => 'GB-ENG', 'name' => 'England', 'type' => 'country'],
|
||||
['country' => 'GB', 'code' => 'GB-SCT', 'name' => 'Scotland', 'type' => 'country'],
|
||||
['country' => 'GB', 'code' => 'GB-WLS', 'name' => 'Wales', 'type' => 'country'],
|
||||
['country' => 'GB', 'code' => 'GB-NIR', 'name' => 'Northern Ireland', 'type' => 'province'],
|
||||
['country' => 'US', 'code' => 'US-AL', 'name' => 'Alabama', 'type' => 'state'],
|
||||
['country' => 'US', 'code' => 'US-AK', 'name' => 'Alaska', 'type' => 'state'],
|
||||
['country' => 'US', 'code' => 'US-AZ', 'name' => 'Arizona', 'type' => 'state'],
|
||||
['country' => 'US', 'code' => 'US-AR', 'name' => 'Arkansas', 'type' => 'state'],
|
||||
['country' => 'US', 'code' => 'US-CA', 'name' => 'California', 'type' => 'state'],
|
||||
['country' => 'US', 'code' => 'US-CO', 'name' => 'Colorado', 'type' => 'state'],
|
||||
['country' => 'US', 'code' => 'US-CT', 'name' => 'Connecticut', 'type' => 'state'],
|
||||
['country' => 'US', 'code' => 'US-DE', 'name' => 'Delaware', 'type' => 'state'],
|
||||
['country' => 'US', 'code' => 'US-FL', 'name' => 'Florida', 'type' => 'state'],
|
||||
['country' => 'US', 'code' => 'US-NY', 'name' => 'New York', 'type' => 'state'],
|
||||
['country' => 'US', 'code' => 'US-TX', 'name' => 'Texas', 'type' => 'state'],
|
||||
['country' => 'US', 'code' => 'US-WA', 'name' => 'Washington', 'type' => 'state'],
|
||||
['country' => 'CA', 'code' => 'CA-AB', 'name' => 'Alberta', 'type' => 'province'],
|
||||
['country' => 'CA', 'code' => 'CA-BC', 'name' => 'British Columbia', 'type' => 'province'],
|
||||
['country' => 'CA', 'code' => 'CA-ON', 'name' => 'Ontario', 'type' => 'province'],
|
||||
['country' => 'CA', 'code' => 'CA-QC', 'name' => 'Quebec', 'type' => 'province'],
|
||||
];
|
||||
|
||||
foreach ($rows as $index => $row) {
|
||||
$country = Country::query()->where('alpha2', $row['country'])->first();
|
||||
|
||||
if (! $country) {
|
||||
continue;
|
||||
}
|
||||
|
||||
CountrySubdivision::query()->updateOrCreate(
|
||||
['code' => $row['code']],
|
||||
[
|
||||
'country_id' => $country->id,
|
||||
'country_alpha2' => $country->alpha2,
|
||||
'subdivision_code' => substr($row['code'], 3),
|
||||
'name' => $row['name'],
|
||||
'local_name' => null,
|
||||
'type' => $row['type'],
|
||||
'is_active' => true,
|
||||
'iso_status' => 'official',
|
||||
'sort_order' => $index + 1,
|
||||
'last_synced_at' => $now,
|
||||
'source' => 'bundled-bootstrap',
|
||||
'source_version' => '2026-06-15',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,26 +6,87 @@ use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\Countries\Models\Country;
|
||||
use Modules\Countries\Models\CountrySubdivision;
|
||||
use Modules\Countries\Support\CountryRepository;
|
||||
|
||||
class CountriesApiController extends Controller
|
||||
{
|
||||
/**
|
||||
* List ISO 3166-1 countries for integrations and selectors.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$activeOnly = $request->boolean('active_only', false);
|
||||
$countries = Country::query()->when($activeOnly, fn ($query) => $query->where('is_active', true))->ordered()
|
||||
->get(['alpha2', 'alpha3', 'numeric', 'name', 'official_name', 'common_name', 'display_name', 'flag_emoji', 'is_active']);
|
||||
$search = trim((string) $request->query('search', ''));
|
||||
|
||||
$countries = Country::query()
|
||||
->when($activeOnly, fn ($query) => $query->where('is_active', true))
|
||||
->when($search !== '', function ($query) use ($search): void {
|
||||
$query->where(function ($nested) use ($search): void {
|
||||
$nested->where('name', 'ilike', "%{$search}%")
|
||||
->orWhere('official_name', 'ilike', "%{$search}%")
|
||||
->orWhere('common_name', 'ilike', "%{$search}%")
|
||||
->orWhere('display_name', 'ilike', "%{$search}%")
|
||||
->orWhere('alpha2', 'ilike', "%{$search}%")
|
||||
->orWhere('alpha3', 'ilike', "%{$search}%")
|
||||
->orWhere('numeric', 'ilike', "%{$search}%")
|
||||
->orWhere('region', 'ilike', "%{$search}%")
|
||||
->orWhere('subregion', 'ilike', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->ordered()
|
||||
->get(['alpha2', 'alpha3', 'numeric', 'name', 'official_name', 'common_name', 'display_name', 'flag_emoji', 'is_active', 'iso_status', 'independent', 'region', 'subregion']);
|
||||
|
||||
return response()->json(['data' => $countries]);
|
||||
}
|
||||
|
||||
/**
|
||||
* List active ISO 3166-1 countries for business selectors.
|
||||
*/
|
||||
public function active(): JsonResponse
|
||||
{
|
||||
$countries = Country::query()->active()->ordered()
|
||||
->get(['alpha2', 'alpha3', 'numeric', 'name', 'display_name', 'common_name', 'flag_emoji']);
|
||||
->get(['alpha2', 'alpha3', 'numeric', 'name', 'display_name', 'common_name', 'flag_emoji', 'region', 'subregion']);
|
||||
|
||||
return response()->json(['data' => $countries]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show one country by alpha-2 route binding.
|
||||
*/
|
||||
public function show(Country $country): JsonResponse
|
||||
{
|
||||
return response()->json(['data' => $country->loadCount('subdivisions')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve alpha-2, alpha-3 or numeric ISO 3166-1 country code.
|
||||
*/
|
||||
public function resolve(string $code, CountryRepository $countries): JsonResponse
|
||||
{
|
||||
$country = $countries->findByIsoCode($code);
|
||||
|
||||
abort_if($country === null, 404, 'Country code not found.');
|
||||
|
||||
return response()->json(['data' => $country]);
|
||||
}
|
||||
|
||||
/**
|
||||
* List ISO 3166-2 subdivisions for the supplied ISO 3166-1 alpha-2 country.
|
||||
*/
|
||||
public function subdivisions(string $alpha2, Request $request, CountryRepository $countries): JsonResponse
|
||||
{
|
||||
$activeOnly = $request->boolean('active_only', true);
|
||||
$country = $countries->findByAlpha2($alpha2);
|
||||
|
||||
abort_if($country === null, 404, 'Country not found.');
|
||||
|
||||
$subdivisions = CountrySubdivision::query()
|
||||
->where('country_id', $country->id)
|
||||
->when($activeOnly, fn ($query) => $query->where('is_active', true))
|
||||
->ordered()
|
||||
->get(['country_alpha2', 'code', 'subdivision_code', 'name', 'local_name', 'type', 'is_active', 'iso_status']);
|
||||
|
||||
return response()->json(['data' => $subdivisions]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ class CountriesController extends Controller
|
||||
'display_name' => $request->validated('display_name'),
|
||||
'is_active' => $request->boolean('is_active'),
|
||||
'sort_order' => (int) ($request->validated('sort_order') ?? 0),
|
||||
'iso_status' => $request->validated('iso_status'),
|
||||
'independent' => $request->boolean('independent'),
|
||||
'region' => $request->validated('region'),
|
||||
'subregion' => $request->validated('subregion'),
|
||||
]);
|
||||
|
||||
return redirect()->route('countries.show', $country)->with('success', 'Country settings updated.');
|
||||
|
||||
@@ -3,20 +3,32 @@
|
||||
namespace Modules\Countries\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Countries\Support\Iso3166;
|
||||
|
||||
class UpdateCountryRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Authorize country reference-data administration.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return (bool) $this->user();
|
||||
return (bool) $this->user()?->isMpbaPlatformAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'display_name' => ['nullable', 'string', 'max:255'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
'sort_order' => ['nullable', 'integer', 'min:0', 'max:999999'],
|
||||
'iso_status' => ['required', 'string', Rule::in(Iso3166::statuses())],
|
||||
'independent' => ['nullable', 'boolean'],
|
||||
'region' => ['nullable', 'string', 'max:100'],
|
||||
'subregion' => ['nullable', 'string', 'max:100'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Modules\Countries\Models;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Country extends Model
|
||||
{
|
||||
@@ -12,13 +13,16 @@ class Country extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'alpha2', 'alpha3', 'numeric', 'name', 'official_name', 'common_name',
|
||||
'display_name', 'flag_emoji', 'is_active', 'sort_order', 'last_synced_at',
|
||||
'display_name', 'flag_emoji', 'is_active', 'iso_status', 'independent', 'region', 'subregion',
|
||||
'sort_order', 'last_synced_at', 'source', 'source_version', 'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
'last_synced_at' => 'datetime',
|
||||
'independent' => 'boolean',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
@@ -31,6 +35,30 @@ class Country extends Model
|
||||
return $this->display_name ?: $this->common_name ?: $this->name;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* ISO 3166-2 subdivisions belonging to this country.
|
||||
*/
|
||||
public function subdivisions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CountrySubdivision::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a country from alpha-2, alpha-3 or numeric ISO 3166-1 code.
|
||||
*/
|
||||
public function scopeForIsoCode(Builder $query, string $code): Builder
|
||||
{
|
||||
$normalised = strtoupper(trim($code));
|
||||
|
||||
return $query->where(function (Builder $nested) use ($normalised): void {
|
||||
$nested->where('alpha2', $normalised)
|
||||
->orWhere('alpha3', $normalised)
|
||||
->orWhere('numeric', str_pad($normalised, 3, '0', STR_PAD_LEFT));
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Countries\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CountrySubdivision extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/** @var list<string> */
|
||||
protected $fillable = [
|
||||
'country_id',
|
||||
'country_alpha2',
|
||||
'code',
|
||||
'subdivision_code',
|
||||
'name',
|
||||
'local_name',
|
||||
'type',
|
||||
'is_active',
|
||||
'iso_status',
|
||||
'sort_order',
|
||||
'last_synced_at',
|
||||
'source',
|
||||
'source_version',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
'last_synced_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* Use the ISO 3166-2 code for route model binding.
|
||||
*/
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'code';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parent ISO 3166-1 country.
|
||||
*/
|
||||
public function country(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Country::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope active subdivisions.
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope ordered subdivisions for selectors and administration screens.
|
||||
*/
|
||||
public function scopeOrdered(Builder $query): Builder
|
||||
{
|
||||
return $query->orderBy('sort_order')->orderBy('name');
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,69 @@
|
||||
<x-layouts.app>
|
||||
<div class="mx-auto max-w-[1000px] space-y-6" dusk="countries-edit">
|
||||
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white"><p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">Country support</p><h1 class="mt-2 text-3xl font-semibold tracking-tight">Edit {{ $country->flag_emoji }} {{ $country->effective_name }}</h1><p class="mt-2 max-w-3xl text-sm text-blue-100/90">ISO codes are locked. Only support status, display name and sort order can be changed.</p></div></div>
|
||||
<form method="POST" action="{{ route('countries.update', $country) }}" class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">@csrf @method('PUT')
|
||||
<div class="grid gap-5"><div><label class="text-sm font-semibold" for="display_name">Display name override</label><input id="display_name" name="display_name" value="{{ old('display_name', $country->display_name) }}" class="mt-2 min-h-11 w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" />@error('display_name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror</div><div><label class="text-sm font-semibold" for="sort_order">Sort order</label><input id="sort_order" name="sort_order" type="number" min="0" value="{{ old('sort_order', $country->sort_order) }}" class="mt-2 min-h-11 w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" />@error('sort_order')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror</div><label class="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-950"><input type="hidden" name="is_active" value="0" /><input type="checkbox" name="is_active" value="1" @checked(old('is_active', $country->is_active)) class="rounded border-zinc-300 text-blue-600 focus:ring-blue-500" /><span><span class="block text-sm font-semibold">Active country</span><span class="block text-xs text-zinc-500">Inactive countries remain in the ISO table but are hidden from normal selection lists.</span></span></label></div>
|
||||
<div class="mt-6 flex flex-wrap gap-2"><button type="submit" class="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">Save country</button><a href="{{ route('countries.show', $country) }}" wire:navigate class="rounded-xl bg-zinc-100 px-4 py-2.5 text-sm font-semibold text-zinc-900 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700">Cancel</a></div>
|
||||
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">ISO 3166 country support</p>
|
||||
<h1 class="mt-2 text-3xl font-semibold tracking-tight">Edit {{ $country->flag_emoji }} {{ $country->effective_name }}</h1>
|
||||
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">ISO identity codes are locked. Platform administrators may control support status, display naming and operational metadata.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('countries.update', $country) }}" class="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="grid gap-5 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<label class="text-sm font-semibold" for="display_name">Display name override</label>
|
||||
<input id="display_name" name="display_name" value="{{ old('display_name', $country->display_name) }}" class="mt-2 min-h-11 w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" />
|
||||
@error('display_name')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-semibold" for="sort_order">Sort order</label>
|
||||
<input id="sort_order" name="sort_order" type="number" min="0" value="{{ old('sort_order', $country->sort_order) }}" class="mt-2 min-h-11 w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" />
|
||||
@error('sort_order')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-semibold" for="iso_status">ISO lifecycle status</label>
|
||||
<select id="iso_status" name="iso_status" class="mt-2 min-h-11 w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100">
|
||||
@foreach (\Modules\Countries\Support\Iso3166::statuses() as $status)
|
||||
<option value="{{ $status }}" @selected(old('iso_status', $country->iso_status ?? 'official') === $status)>{{ str($status)->replace('_', ' ')->title() }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('iso_status')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-semibold" for="region">Region</label>
|
||||
<input id="region" name="region" value="{{ old('region', $country->region) }}" class="mt-2 min-h-11 w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" />
|
||||
@error('region')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-semibold" for="subregion">Subregion</label>
|
||||
<input id="subregion" name="subregion" value="{{ old('subregion', $country->subregion) }}" class="mt-2 min-h-11 w-full rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" />
|
||||
@error('subregion')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<input type="hidden" name="is_active" value="0" />
|
||||
<input type="checkbox" name="is_active" value="1" @checked(old('is_active', $country->is_active)) class="rounded border-zinc-300 text-blue-600 focus:ring-blue-500" />
|
||||
<span><span class="block text-sm font-semibold">Supported in business workflows</span><span class="block text-xs text-zinc-500">Unsupported countries stay in the ISO table but are hidden from normal selectors.</span></span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-950">
|
||||
<input type="hidden" name="independent" value="0" />
|
||||
<input type="checkbox" name="independent" value="1" @checked(old('independent', $country->independent)) class="rounded border-zinc-300 text-blue-600 focus:ring-blue-500" />
|
||||
<span><span class="block text-sm font-semibold">Independent state</span><span class="block text-xs text-zinc-500">Optional reference flag for reporting and compliance workflows.</span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap gap-2">
|
||||
<button type="submit" class="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">Save country</button>
|
||||
<a href="{{ route('countries.show', $country) }}" wire:navigate class="rounded-xl bg-zinc-100 px-4 py-2.5 text-sm font-semibold text-zinc-900 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</x-layouts.app>
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
<select name="status" class="min-h-11 rounded-xl border-zinc-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"><option value="all" @selected($status === 'all')>All</option><option value="active" @selected($status === 'active')>Active</option><option value="inactive" @selected($status === 'inactive')>Inactive</option></select>
|
||||
<button type="submit" class="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-700">Filter</button>
|
||||
</form>
|
||||
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><div class="overflow-x-auto"><table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-800"><thead class="bg-zinc-50 dark:bg-zinc-950"><tr><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Country</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Alpha-2</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Alpha-3</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Numeric</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Status</th><th class="px-4 py-3 text-right text-xs font-bold uppercase tracking-wide text-zinc-500">Actions</th></tr></thead><tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
@forelse ($countries as $country)<tr class="hover:bg-blue-50/50 dark:hover:bg-blue-950/20"><td class="px-4 py-3"><div class="flex items-center gap-3"><span class="text-xl">{{ $country->flag_emoji }}</span><div><div class="font-semibold text-zinc-900 dark:text-zinc-100">{{ $country->effective_name }}</div><div class="text-xs text-zinc-500">{{ $country->official_name ?: $country->name }}</div></div></div></td><td class="px-4 py-3 font-mono text-sm text-blue-700 dark:text-blue-300">{{ $country->alpha2 }}</td><td class="px-4 py-3 font-mono text-sm">{{ $country->alpha3 }}</td><td class="px-4 py-3 font-mono text-sm">{{ $country->numeric }}</td><td class="px-4 py-3">@if ($country->is_active)<span class="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-bold text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">Active</span>@else<span class="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-bold text-amber-800 dark:bg-amber-950 dark:text-amber-200">Inactive</span>@endif</td><td class="px-4 py-3 text-right"><a href="{{ route('countries.show', $country) }}" wire:navigate class="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-700">Open</a> <a href="{{ route('countries.edit', $country) }}" wire:navigate class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Edit</a></td></tr>@empty<tr><td colspan="6" class="px-4 py-10 text-center text-sm text-zinc-500">No countries matched your search.</td></tr>@endforelse
|
||||
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><div class="overflow-x-auto"><table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-800"><thead class="bg-zinc-50 dark:bg-zinc-950"><tr><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Country</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Alpha-2</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Alpha-3</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Numeric</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">ISO status</th><th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Status</th><th class="px-4 py-3 text-right text-xs font-bold uppercase tracking-wide text-zinc-500">Actions</th></tr></thead><tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
@forelse ($countries as $country)<tr class="hover:bg-blue-50/50 dark:hover:bg-blue-950/20"><td class="px-4 py-3"><div class="flex items-center gap-3"><span class="text-xl">{{ $country->flag_emoji }}</span><div><div class="font-semibold text-zinc-900 dark:text-zinc-100">{{ $country->effective_name }}</div><div class="text-xs text-zinc-500">{{ $country->official_name ?: $country->name }}</div></div></div></td><td class="px-4 py-3 font-mono text-sm text-blue-700 dark:text-blue-300">{{ $country->alpha2 }}</td><td class="px-4 py-3 font-mono text-sm">{{ $country->alpha3 }}</td><td class="px-4 py-3 font-mono text-sm">{{ $country->numeric }}</td><td class="px-4 py-3 text-sm text-zinc-500">{{ str($country->iso_status ?? 'official')->replace('_', ' ')->title() }}</td><td class="px-4 py-3">@if ($country->is_active)<span class="rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-bold text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">Active</span>@else<span class="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-bold text-amber-800 dark:bg-amber-950 dark:text-amber-200">Inactive</span>@endif</td><td class="px-4 py-3 text-right"><a href="{{ route('countries.show', $country) }}" wire:navigate class="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-700">Open</a> <a href="{{ route('countries.edit', $country) }}" wire:navigate class="rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Edit</a></td></tr>@empty<tr><td colspan="7" class="px-4 py-10 text-center text-sm text-zinc-500">No countries matched your search.</td></tr>@endforelse
|
||||
</tbody></table></div><div class="border-t border-zinc-200 px-4 py-3 dark:border-zinc-800">{{ $countries->links() }}</div></div>
|
||||
</div>
|
||||
</x-layouts.app>
|
||||
|
||||
@@ -1,7 +1,72 @@
|
||||
<x-layouts.app>
|
||||
<div class="mx-auto max-w-[1200px] space-y-6" dusk="countries-show">
|
||||
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white"><p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">ISO country</p><h1 class="mt-2 text-3xl font-semibold tracking-tight">{{ $country->flag_emoji }} {{ $country->effective_name }}</h1><p class="mt-2 max-w-3xl text-sm text-blue-100/90">{{ $country->official_name ?: $country->name }}</p><div class="mt-4 flex gap-2"><a href="{{ route('countries.edit', $country) }}" wire:navigate class="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700">Edit support</a><a href="{{ route('countries.index') }}" wire:navigate class="rounded-xl bg-white/10 px-4 py-2.5 text-sm font-semibold text-white ring-1 ring-white/20 hover:bg-white/20">Back</a></div></div></div>
|
||||
@if (session('success'))<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200">{{ session('success') }}</div>@endif
|
||||
<div class="grid gap-4 md:grid-cols-2">@foreach (['Alpha-2' => $country->alpha2, 'Alpha-3' => $country->alpha3, 'Numeric' => $country->numeric, 'Canonical name' => $country->name, 'Official name' => $country->official_name ?: '—', 'Common name' => $country->common_name ?: '—', 'Display override' => $country->display_name ?: '—', 'Active' => $country->is_active ? 'Yes' : 'No'] as $label => $value)<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"><div class="text-xs font-bold uppercase tracking-wide text-zinc-500">{{ $label }}</div><div class="mt-2 text-base font-semibold text-zinc-900 dark:text-zinc-100">{{ $value }}</div></div>@endforeach</div>
|
||||
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 px-6 py-7 text-white">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-blue-200">ISO 3166 country</p>
|
||||
<h1 class="mt-2 text-3xl font-semibold tracking-tight">{{ $country->flag_emoji }} {{ $country->effective_name }}</h1>
|
||||
<p class="mt-2 max-w-3xl text-sm text-blue-100/90">{{ $country->official_name ?: $country->name }}</p>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<a href="{{ route('countries.edit', $country) }}" wire:navigate class="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700">Edit support</a>
|
||||
<a href="{{ route('countries.index') }}" wire:navigate class="rounded-xl bg-white/10 px-4 py-2.5 text-sm font-semibold text-white ring-1 ring-white/20 hover:bg-white/20">Back</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (session('success'))
|
||||
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
@foreach ([
|
||||
'Alpha-2' => $country->alpha2,
|
||||
'Alpha-3' => $country->alpha3,
|
||||
'Numeric' => $country->numeric,
|
||||
'Canonical name' => $country->name,
|
||||
'Official name' => $country->official_name ?: '—',
|
||||
'Common name' => $country->common_name ?: '—',
|
||||
'Display override' => $country->display_name ?: '—',
|
||||
'ISO status' => str($country->iso_status ?? 'official')->replace('_', ' ')->title(),
|
||||
'Region' => $country->region ?: '—',
|
||||
'Subregion' => $country->subregion ?: '—',
|
||||
'Independent' => is_null($country->independent) ? '—' : ($country->independent ? 'Yes' : 'No'),
|
||||
'Active' => $country->is_active ? 'Yes' : 'No',
|
||||
] as $label => $value)
|
||||
<div class="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wide text-zinc-500">{{ $label }}</div>
|
||||
<div class="mt-2 text-base font-semibold text-zinc-900 dark:text-zinc-100">{{ $value }}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="border-b border-zinc-200 px-5 py-4 dark:border-zinc-800">
|
||||
<h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">ISO 3166-2 subdivisions</h2>
|
||||
<p class="text-xs text-zinc-500">Subdivisions are stored separately for address, compliance and shipping workflows.</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
<thead class="bg-zinc-50 dark:bg-zinc-950">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Code</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Name</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Type</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-bold uppercase tracking-wide text-zinc-500">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
@forelse ($country->subdivisions()->ordered()->limit(100)->get() as $subdivision)
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-mono text-sm text-blue-700 dark:text-blue-300">{{ $subdivision->code }}</td>
|
||||
<td class="px-4 py-3 text-sm font-semibold text-zinc-900 dark:text-zinc-100">{{ $subdivision->name }}</td>
|
||||
<td class="px-4 py-3 text-sm text-zinc-500">{{ $subdivision->type ?: '—' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-zinc-500">{{ str($subdivision->iso_status)->replace('_', ' ')->title() }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-zinc-500">No subdivisions have been imported for this country yet.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-layouts.app>
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Countries\Http\Controllers\Api\CountriesApiController;
|
||||
|
||||
Route::prefix('countries')->name('api.countries.')->group(function (): void {
|
||||
Route::middleware(['api'])->prefix('api/countries')->name('api.countries.')->group(function (): void {
|
||||
Route::get('/', [CountriesApiController::class, 'index'])->name('index');
|
||||
Route::get('/active', [CountriesApiController::class, 'active'])->name('active');
|
||||
Route::get('/resolve/{code}', [CountriesApiController::class, 'resolve'])->where('code', '[A-Za-z0-9]{2,3}')->name('resolve');
|
||||
Route::get('/{country:alpha2}', [CountriesApiController::class, 'show'])->name('show');
|
||||
Route::get('/{alpha2}/subdivisions', [CountriesApiController::class, 'subdivisions'])->where('alpha2', '[A-Za-z]{2}')->name('subdivisions');
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace Modules\Countries\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Modules\Countries\Models\Country;
|
||||
|
||||
class ValidCountryCode implements ValidationRule
|
||||
{
|
||||
@@ -14,19 +13,6 @@ class ValidCountryCode implements ValidationRule
|
||||
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value) || strlen($value) !== 2) {
|
||||
$fail('The :attribute must be a valid ISO 3166 alpha-2 country code.');
|
||||
return;
|
||||
}
|
||||
|
||||
$query = Country::query()->where('alpha2', strtoupper($value));
|
||||
|
||||
if ($this->activeOnly) {
|
||||
$query->where('is_active', true);
|
||||
}
|
||||
|
||||
if (! $query->exists()) {
|
||||
$fail('The :attribute must be a supported country.');
|
||||
}
|
||||
(new ValidIso3166CountryCode($this->activeOnly, 'alpha2'))->validate($attribute, $value, $fail);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Countries\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Modules\Countries\Models\Country;
|
||||
|
||||
class ValidIso3166CountryCode implements ValidationRule
|
||||
{
|
||||
public function __construct(
|
||||
private readonly bool $activeOnly = true,
|
||||
private readonly string $format = 'alpha2',
|
||||
) {
|
||||
}
|
||||
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value) && ! is_int($value)) {
|
||||
$fail('The :attribute must be a valid ISO 3166 country code.');
|
||||
return;
|
||||
}
|
||||
|
||||
$code = strtoupper(trim((string) $value));
|
||||
$query = Country::query();
|
||||
|
||||
match ($this->format) {
|
||||
'alpha2' => $query->where('alpha2', $code),
|
||||
'alpha3' => $query->where('alpha3', $code),
|
||||
'numeric' => $query->where('numeric', str_pad($code, 3, '0', STR_PAD_LEFT)),
|
||||
'any' => $query->forIsoCode($code),
|
||||
default => $query->whereRaw('1 = 0'),
|
||||
};
|
||||
|
||||
if ($this->activeOnly) {
|
||||
$query->where('is_active', true);
|
||||
}
|
||||
|
||||
if (! $query->exists()) {
|
||||
$fail('The :attribute must be a supported ISO 3166 country code.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Countries\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Modules\Countries\Models\CountrySubdivision;
|
||||
|
||||
class ValidIso3166SubdivisionCode implements ValidationRule
|
||||
{
|
||||
public function __construct(private readonly bool $activeOnly = true)
|
||||
{
|
||||
}
|
||||
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value) || ! preg_match('/^[A-Z]{2}-[A-Z0-9]{1,6}$/', strtoupper($value))) {
|
||||
$fail('The :attribute must be a valid ISO 3166-2 subdivision code.');
|
||||
return;
|
||||
}
|
||||
|
||||
$query = CountrySubdivision::query()->where('code', strtoupper($value));
|
||||
|
||||
if ($this->activeOnly) {
|
||||
$query->where('is_active', true);
|
||||
}
|
||||
|
||||
if (! $query->exists()) {
|
||||
$fail('The :attribute must be a supported ISO 3166-2 subdivision code.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,49 @@ namespace Modules\Countries\Support;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Modules\Countries\Models\Country;
|
||||
use Modules\Countries\Models\CountrySubdivision;
|
||||
|
||||
class CountryRepository
|
||||
{
|
||||
/**
|
||||
* Active countries for form selects.
|
||||
*
|
||||
* @return Collection<int, Country>
|
||||
*/
|
||||
public function activeForSelect(): Collection
|
||||
{
|
||||
return Country::query()->active()->ordered()
|
||||
->get(['id', 'alpha2', 'alpha3', 'numeric', 'name', 'display_name', 'common_name', 'flag_emoji']);
|
||||
->get(['id', 'alpha2', 'alpha3', 'numeric', 'name', 'display_name', 'common_name', 'flag_emoji', 'region', 'subregion']);
|
||||
}
|
||||
|
||||
public function findByAlpha2(string $alpha2): ?Country
|
||||
{
|
||||
return Country::query()->where('alpha2', strtoupper($alpha2))->first();
|
||||
return Country::query()->where('alpha2', Iso3166::normaliseAlpha2($alpha2))->first();
|
||||
}
|
||||
|
||||
public function findByIsoCode(string $code): ?Country
|
||||
{
|
||||
return Country::query()->forIsoCode($code)->first();
|
||||
}
|
||||
|
||||
public function findSubdivision(string $code): ?CountrySubdivision
|
||||
{
|
||||
return CountrySubdivision::query()->where('code', strtoupper(trim($code)))->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Active subdivisions for a country select.
|
||||
*
|
||||
* @return Collection<int, CountrySubdivision>
|
||||
*/
|
||||
public function activeSubdivisionsForCountry(string $alpha2): Collection
|
||||
{
|
||||
$alpha2 = Iso3166::normaliseAlpha2($alpha2);
|
||||
|
||||
return CountrySubdivision::query()
|
||||
->where('country_alpha2', $alpha2)
|
||||
->active()
|
||||
->ordered()
|
||||
->get(['id', 'country_alpha2', 'code', 'subdivision_code', 'name', 'local_name', 'type']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Countries\Support;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class Iso3166
|
||||
{
|
||||
public const STATUS_OFFICIAL = 'official';
|
||||
public const STATUS_RESERVED = 'reserved';
|
||||
public const STATUS_TRANSITIONAL = 'transitional';
|
||||
public const STATUS_DELETED = 'deleted';
|
||||
public const STATUS_USER_ASSIGNED = 'user_assigned';
|
||||
public const STATUS_EXCEPTIONALLY_RESERVED = 'exceptionally_reserved';
|
||||
|
||||
/** @return list<string> */
|
||||
public static function statuses(): array
|
||||
{
|
||||
return [
|
||||
self::STATUS_OFFICIAL,
|
||||
self::STATUS_RESERVED,
|
||||
self::STATUS_TRANSITIONAL,
|
||||
self::STATUS_DELETED,
|
||||
self::STATUS_USER_ASSIGNED,
|
||||
self::STATUS_EXCEPTIONALLY_RESERVED,
|
||||
];
|
||||
}
|
||||
|
||||
public static function normaliseAlpha2(string $code): string
|
||||
{
|
||||
$normalised = strtoupper(trim($code));
|
||||
|
||||
if (! preg_match('/^[A-Z]{2}$/', $normalised)) {
|
||||
throw new InvalidArgumentException('ISO 3166-1 alpha-2 codes must contain exactly two letters.');
|
||||
}
|
||||
|
||||
return $normalised;
|
||||
}
|
||||
|
||||
public static function normaliseAlpha3(string $code): string
|
||||
{
|
||||
$normalised = strtoupper(trim($code));
|
||||
|
||||
if (! preg_match('/^[A-Z]{3}$/', $normalised)) {
|
||||
throw new InvalidArgumentException('ISO 3166-1 alpha-3 codes must contain exactly three letters.');
|
||||
}
|
||||
|
||||
return $normalised;
|
||||
}
|
||||
|
||||
public static function normaliseNumeric(string|int $code): string
|
||||
{
|
||||
$normalised = str_pad((string) $code, 3, '0', STR_PAD_LEFT);
|
||||
|
||||
if (! preg_match('/^[0-9]{3}$/', $normalised)) {
|
||||
throw new InvalidArgumentException('ISO 3166-1 numeric codes must contain exactly three digits.');
|
||||
}
|
||||
|
||||
return $normalised;
|
||||
}
|
||||
|
||||
public static function normaliseSubdivisionCode(string $countryAlpha2, string $subdivisionCode): string
|
||||
{
|
||||
$country = self::normaliseAlpha2($countryAlpha2);
|
||||
$code = strtoupper(trim($subdivisionCode));
|
||||
|
||||
if (str_starts_with($code, $country.'-')) {
|
||||
return $code;
|
||||
}
|
||||
|
||||
if (! preg_match('/^[A-Z0-9]{1,6}$/', $code)) {
|
||||
throw new InvalidArgumentException('ISO 3166-2 subdivision codes must contain 1 to 6 letters or numbers after the country prefix.');
|
||||
}
|
||||
|
||||
return $country.'-'.$code;
|
||||
}
|
||||
|
||||
public static function flagEmoji(string $alpha2): string
|
||||
{
|
||||
$alpha2 = self::normaliseAlpha2($alpha2);
|
||||
$base = 127397;
|
||||
|
||||
return mb_chr($base + ord($alpha2[0]), 'UTF-8').mb_chr($base + ord($alpha2[1]), 'UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Countries\Tests\Feature;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Modules\Countries\Database\Seeders\CountriesDatabaseSeeder;
|
||||
use Modules\Countries\Models\Country;
|
||||
use Modules\Countries\Models\CountrySubdivision;
|
||||
use Modules\Countries\Rules\ValidIso3166CountryCode;
|
||||
use Modules\Countries\Rules\ValidIso3166SubdivisionCode;
|
||||
use Tests\TestCase;
|
||||
|
||||
class Iso3166SupportTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_seeds_iso_3166_country_and_subdivision_reference_data(): void
|
||||
{
|
||||
$this->seed(CountriesDatabaseSeeder::class);
|
||||
|
||||
$this->assertDatabaseHas('countries', [
|
||||
'alpha2' => 'GB',
|
||||
'alpha3' => 'GBR',
|
||||
'numeric' => '826',
|
||||
'iso_status' => 'official',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('country_subdivisions', [
|
||||
'country_alpha2' => 'GB',
|
||||
'code' => 'GB-ENG',
|
||||
'name' => 'England',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_resolves_country_by_any_iso_3166_1_code(): void
|
||||
{
|
||||
$this->seed(CountriesDatabaseSeeder::class);
|
||||
|
||||
$this->getJson('/api/countries/resolve/GBR')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.alpha2', 'GB');
|
||||
|
||||
$this->getJson('/api/countries/resolve/826')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.alpha3', 'GBR');
|
||||
}
|
||||
|
||||
public function test_it_lists_iso_3166_2_subdivisions_for_country(): void
|
||||
{
|
||||
$this->seed(CountriesDatabaseSeeder::class);
|
||||
|
||||
$this->getJson('/api/countries/GB/subdivisions')
|
||||
->assertOk()
|
||||
->assertJsonFragment(['code' => 'GB-ENG'])
|
||||
->assertJsonFragment(['code' => 'GB-SCT']);
|
||||
}
|
||||
|
||||
public function test_validation_rules_support_country_and_subdivision_codes(): void
|
||||
{
|
||||
$this->seed(CountriesDatabaseSeeder::class);
|
||||
|
||||
$countryRule = new ValidIso3166CountryCode(activeOnly: true, format: 'any');
|
||||
$subdivisionRule = new ValidIso3166SubdivisionCode(activeOnly: true);
|
||||
|
||||
$countryFailed = false;
|
||||
$subdivisionFailed = false;
|
||||
|
||||
$countryRule->validate('country', 'GBR', function () use (&$countryFailed): void {
|
||||
$countryFailed = true;
|
||||
});
|
||||
|
||||
$subdivisionRule->validate('subdivision', 'GB-ENG', function () use (&$subdivisionFailed): void {
|
||||
$subdivisionFailed = true;
|
||||
});
|
||||
|
||||
$this->assertFalse($countryFailed);
|
||||
$this->assertFalse($subdivisionFailed);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,19 @@
|
||||
1.2.1:
|
||||
date: 2026-06-15
|
||||
summary: "Restored Countries module route names to countries.* to match the registered application route and prevent RouteNotFoundException."
|
||||
|
||||
module: Countries
|
||||
current_version: 1.2.0
|
||||
entries:
|
||||
versions:
|
||||
- version: 1.2.0
|
||||
date: 2026-06-15
|
||||
type: change
|
||||
summary: "Added Countries to the WMS Platform Administration sidebar for MPBA platform administrators using the existing route guard, active-state class, and sidebar styling."
|
||||
changes:
|
||||
- Added ISO 3166-1 lifecycle metadata fields to countries.
|
||||
- Added ISO 3166-2 country subdivision table and model.
|
||||
- Added ISO 3166 helper for alpha-2, alpha-3, numeric and subdivision code normalisation.
|
||||
- Added API endpoints for resolving country codes and listing subdivisions.
|
||||
- Added validation rules for ISO 3166-1 and ISO 3166-2 codes.
|
||||
- Added bootstrap subdivision seed data for GB, US and CA.
|
||||
- Updated administration views for ISO status, region, subregion and subdivision visibility.
|
||||
- version: 1.1.0
|
||||
date: 2026-06-15
|
||||
type: feature
|
||||
summary: "Completed ISO 3166 countries reference module with seeded country data, professional WMS UI, admin activation controls, API lookup endpoints, validation rule, and tests."
|
||||
changes:
|
||||
- Added ISO country reference administration support.
|
||||
- version: 1.0.0
|
||||
date: 2026-06-15
|
||||
type: initial
|
||||
summary: "Initial module skeleton."
|
||||
- version: 1.1.0
|
||||
date: 2026-06-15
|
||||
summary: "Moved Countries to Platform Administration using platform.countries.* route names under /platform/countries."
|
||||
changes:
|
||||
- Initial Countries module skeleton.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Countries",
|
||||
"alias": "countries",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.0",
|
||||
"description": "ISO 3166 country reference module with active-country support for WMS and SaaS workflows.",
|
||||
"keywords": [
|
||||
"countries",
|
||||
|
||||
Reference in New Issue
Block a user