# Service Provider Public Directory Web Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build an SEO-friendly public service-provider directory with filters, nearby search, synchronized Google map, catalog data, and complete public location detail.

**Architecture:** Laravel handlers and an Infrastructure repository render the first page and detail server-side. Same-origin public bridge endpoints power progressive enhancement; focused JS models/viewmodels synchronize URL filters, cards, geolocation, and markers without exposing private credentials.

**Tech Stack:** PHP 8.3+, Laravel 13, Blade, Laravel HTTP client, vanilla JavaScript, Laravel Mix, PHPUnit 12, Google Maps JavaScript API.

---

### Task 1: Public provider repository, query objects, and handlers

**Files:**
- Create: `app/Domain/ServiceProvider/NearbyRadius.php`
- Create: `app/Application/ServiceProvider/Contracts/PublicServiceProviderRepository.php`
- Create: `app/Application/ServiceProvider/PublicProviderFilters.php`
- Create: `app/Application/ServiceProvider/ShowProviderDirectoryHandler.php`
- Create: `app/Application/ServiceProvider/ShowProviderLocationHandler.php`
- Create: `app/Application/ServiceProvider/ListServiceCatalogHandler.php`
- Create: `app/Infrastructure/Http/ServiceProvider/ApiPublicServiceProviderRepository.php`
- Modify: `app/Providers/AppServiceProvider.php`
- Create: `tests/Unit/Application/ServiceProvider/PublicProviderFiltersTest.php`
- Create: `tests/Feature/PublicServiceProviderRepositoryTest.php`

- [ ] **Step 1: Write failing filter tests**

```php
public function test_filters_drop_unknown_values_and_bound_page_size(): void
{
    $filters = PublicProviderFilters::fromArray([
        'scope' => 'pets',
        'verified' => '1',
        'open_now' => 'true',
        'radius_km' => '25',
        'page' => '2',
        'per_page' => '999',
        'arbitrary' => 'ignored',
    ]);

    self::assertSame([
        'scope' => 'pets',
        'verified' => true,
        'open_now' => true,
        'radius_km' => 25,
        'page' => 2,
        'per_page' => 50,
    ], $filters->toQuery());
}
```

Add tests for permitted radii `[5, 10, 25, 50, 100, 200]`, latitude and
longitude bounds, empty values, catalog IDs, region/commune, emergency,
24-hour, home service, and search.

- [ ] **Step 2: Run focused tests**

Run: `php artisan test tests/Unit/Application/ServiceProvider/PublicProviderFiltersTest.php tests/Feature/PublicServiceProviderRepositoryTest.php`

Expected: FAIL because classes are absent.

- [ ] **Step 3: Define repository contract**

```php
interface PublicServiceProviderRepository
{
    public function providers(PublicProviderFilters $filters): array;
    public function nearby(PublicProviderFilters $filters): array;
    public function location(string $locationId): array;
    public function catalog(string $catalog): array;
}
```

- [ ] **Step 4: Implement sanitized upstream adapter**

Map to:

- `/api/v1/public/service-providers`;
- `/api/v1/public/service-locations/nearby`;
- `/api/v1/public/service-locations/{id}`;
- `/api/v1/public/service-catalogs/{catalog}`.

If upstream is unavailable, list handlers return an empty paginated structure
plus a recoverable error flag; detail returns `null` so Presentation can render
404/503 without leaking exception text.

- [ ] **Step 5: Bind and test**

Run: `php artisan test tests/Unit/Application/ServiceProvider/PublicProviderFiltersTest.php tests/Feature/PublicServiceProviderRepositoryTest.php`

Expected: PASS and `Http::assertSent()` shows only allowed query keys.

- [ ] **Step 6: Commit**

```bash
git add app/Domain/ServiceProvider app/Application/ServiceProvider app/Infrastructure/Http/ServiceProvider app/Providers/AppServiceProvider.php tests
git commit -m "feat: add public provider directory application layer"
```

Choose **Mantener**.

### Task 2: Server-rendered directory and detail routes

**Files:**
- Create: `app/Presentation/ViewModels/PublicProviderDirectoryViewModel.php`
- Create: `app/Presentation/ViewModels/PublicProviderLocationViewModel.php`
- Create: `app/Presentation/ViewModels/PublicProviderViewModelFactory.php`
- Create: `app/Presentation/Http/Controllers/Web/PublicServiceProviderController.php`
- Create: `resources/views/service-providers/index.blade.php`
- Create: `resources/views/service-providers/show.blade.php`
- Create: `resources/views/service-providers/partials/header.blade.php`
- Create: `resources/views/service-providers/partials/filters.blade.php`
- Create: `resources/views/service-providers/partials/result-list.blade.php`
- Create: `resources/views/service-providers/partials/map.blade.php`
- Create: `resources/views/service-providers/partials/location-detail.blade.php`
- Modify: `routes/web.php`
- Create: `tests/Feature/PublicServiceProviderPagesTest.php`

- [ ] **Step 1: Write failing page tests**

```php
public function test_directory_renders_api_results_and_canonical_url(): void
{
    Http::fake(['https://api.mycode.cl/api/v1/public/service-providers*' => Http::response([
        'data' => [[
            'id' => 7,
            'display_name' => 'Veterinaria Central',
            'verified' => true,
            'categories' => [],
        ]],
        'links' => [],
        'meta' => ['current_page' => 1, 'last_page' => 1, 'total' => 1],
    ])]);

    $this->get('/prestadores?search=veterinaria')
        ->assertOk()
        ->assertSee('Veterinaria Central')
        ->assertSee('rel="canonical"', false);
}
```

Add tests for `/prestadores/cerca`, `/prestadores/{location}`, not found,
upstream unavailable, empty list, pagination, and no private provider states.

- [ ] **Step 2: Run tests**

Run: `php artisan test tests/Feature/PublicServiceProviderPagesTest.php`

Expected: FAIL.

- [ ] **Step 3: Implement controllers and ViewModels**

Controller methods:

```php
public function index(Request $request): View
{
    return view(
        'service-providers.index',
        $this->factory->directory($request->query())->toViewData(),
    );
}

public function show(string $location): View
{
    $viewModel = $this->factory->location($location);
    abort_if($viewModel === null, 404);

    return view('service-providers.show', $viewModel->toViewData());
}
```

ViewModels contain normalized display values, same-origin URLs, pagination,
locale, and map availability; they do not contain API credentials.

- [ ] **Step 4: Build semantic Blade views**

Use one H1, landmarks, labeled filters, `<article>` cards, `<address>`,
text status, and pagination links. Initial content must remain useful without
JavaScript or Google Maps.

- [ ] **Step 5: Run tests**

Run: `php artisan test tests/Feature/PublicServiceProviderPagesTest.php`

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add app/Presentation resources/views/service-providers routes/web.php tests/Feature/PublicServiceProviderPagesTest.php
git commit -m "feat: render public provider directory pages"
```

Choose **Mantener**.

### Task 3: Same-origin public data endpoints

**Files:**
- Create: `app/Presentation/Http/Controllers/Bridge/PublicServiceProviderBridgeController.php`
- Create: `app/Application/Bridge/PublicProviderQueryResolver.php`
- Modify: `routes/api.php`
- Create: `tests/Feature/PublicServiceProviderBridgeTest.php`

- [ ] **Step 1: Write failing bridge tests**

```php
public function test_public_nearby_bridge_forwards_validated_coordinates(): void
{
    Http::fake(['https://api.mycode.cl/api/v1/public/service-locations/nearby*' => Http::response([
        'data' => [],
    ])]);

    $this->getJson('/api/bridge/public/service-locations/nearby?latitude=-33.4&longitude=-70.6&radius_km=25')
        ->assertOk();

    Http::assertSent(fn (Request $request) =>
        $request->data()['radius_km'] === 25
        && ! array_key_exists('access_token', $request->data())
    );
}
```

Add tests for list, location, each catalog, invalid radius/coordinates, unknown
paths, and `per_page` cap.

- [ ] **Step 2: Run tests**

Run: `php artisan test tests/Feature/PublicServiceProviderBridgeTest.php`

Expected: FAIL.

- [ ] **Step 3: Implement explicit controller methods**

Use separate methods instead of a wildcard public proxy:

```php
public function providers(Request $request): JsonResponse;
public function nearby(Request $request): JsonResponse;
public function location(string $location): JsonResponse;
public function catalog(string $catalog): JsonResponse;
```

The catalog parameter is limited to `categories`, `services`, `specialties`,
and `features`. Category children are the subcategory source.

- [ ] **Step 4: Register rate-limited routes**

```php
Route::prefix('bridge/public')->middleware('throttle:60,1')->group(function (): void {
    Route::get('/service-providers', [PublicServiceProviderBridgeController::class, 'providers']);
    Route::get('/service-locations/nearby', [PublicServiceProviderBridgeController::class, 'nearby']);
    Route::get('/service-locations/{location}', [PublicServiceProviderBridgeController::class, 'location']);
    Route::get('/service-catalogs/{catalog}', [PublicServiceProviderBridgeController::class, 'catalog']);
});
```

- [ ] **Step 5: Run tests**

Run: `php artisan test tests/Feature/PublicServiceProviderBridgeTest.php`

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add app/Presentation/Http/Controllers/Bridge/PublicServiceProviderBridgeController.php app/Application/Bridge/PublicProviderQueryResolver.php routes/api.php tests/Feature/PublicServiceProviderBridgeTest.php
git commit -m "feat: expose validated public provider data"
```

Choose **Mantener**.

### Task 4: Progressive directory filters and result list

**Files:**
- Create: `resources/js/service-providers/00-config.js`
- Create: `resources/js/service-providers/01-http.js`
- Create: `resources/js/service-providers/02-filter-model.js`
- Create: `resources/js/service-providers/03-results-view.js`
- Create: `resources/js/service-providers/04-directory-viewmodel.js`
- Create: `public/css/service-providers/01-base.css`
- Create: `public/css/service-providers/02-filters-results.css`
- Modify: `webpack.mix.js`
- Modify: `resources/views/service-providers/index.blade.php`
- Create: `tests/js/public-provider-filters.test.js`
- Modify: `package.json`

- [ ] **Step 1: Write failing filter tests**

```javascript
assert.deepStrictEqual(filtersFromForm(form), {
    search: 'veterinaria',
    scope: 'pets',
    category: 'veterinaria',
    subcategory: null,
    service: 'consulta-general',
    specialty: null,
    feature: null,
    region: 'Región Metropolitana',
    commune: 'Providencia',
    verified: true,
    emergency: false,
    open_now: true,
    is_24_hours: false,
    provides_home_service: true,
    page: 1,
    per_page: 12,
});
```

Assert empty fields leave the URL and stale requests are cancelled.

- [ ] **Step 2: Run JS tests**

Run: `node tests/js/public-provider-filters.test.js`

Expected: FAIL.

- [ ] **Step 3: Implement filter model and history synchronization**

`02-filter-model.js` exports `filtersFromForm`, `filtersFromUrl`, and
`filtersToSearchParams`. Booleans use `1`, numeric IDs stay numeric, and only
known keys are serialized.

- [ ] **Step 4: Implement directory viewmodel and results**

Submit and pagination request same-origin JSON, update the result count/list,
replace URL query using `history.pushState`, restore filters on `popstate`,
and preserve the server-rendered list if enhancement fails.

- [ ] **Step 5: Add responsive, accessible styles**

Desktop uses filter sidebar plus results; mobile uses an expandable filter
sheet and card list. Loading uses `aria-busy`; result counts use
`aria-live="polite"`.

- [ ] **Step 6: Run tests and build**

Run: `node tests/js/public-provider-filters.test.js && npm run production`

Expected: PASS and generated `public/js/service-providers/directory.js`.

- [ ] **Step 7: Commit**

```bash
git add resources/js/service-providers public/css/service-providers resources/views/service-providers/index.blade.php webpack.mix.js public/js/service-providers tests/js package.json package-lock.json
git commit -m "feat: add interactive public provider filters"
```

Choose **Mantener**.

### Task 5: Nearby geolocation and synchronized Google map

**Files:**
- Create: `resources/js/service-providers/05-map-adapter.js`
- Create: `resources/js/service-providers/06-nearby-viewmodel.js`
- Create: `resources/js/service-providers/07-map-results-sync.js`
- Create: `public/css/service-providers/03-map.css`
- Modify: `resources/views/service-providers/partials/map.blade.php`
- Modify: `resources/views/service-providers/index.blade.php`
- Modify: `webpack.mix.js`
- Create: `tests/js/public-provider-map.test.js`
- Create: `tests/Feature/PublicServiceProviderNearbyPageTest.php`

- [ ] **Step 1: Write failing map tests**

```javascript
assert.deepStrictEqual(buildNearbyQuery({
    latitude: -33.45,
    longitude: -70.66,
    radius_km: 10,
    filters: { category: 'veterinaria', open_now: true },
}), {
    latitude: -33.45,
    longitude: -70.66,
    radius_km: 10,
    category: 'veterinaria',
    open_now: true,
});
```

Assert marker selection activates the corresponding card, card focus opens the
marker, map bounds include all valid coordinates, and denied geolocation
preserves manual filters.

- [ ] **Step 2: Run tests**

Run: `node tests/js/public-provider-map.test.js && php artisan test tests/Feature/PublicServiceProviderNearbyPageTest.php`

Expected: FAIL.

- [ ] **Step 3: Implement map adapter**

`createProviderMap()` accepts a DOM element and injected Maps API object,
returning `setLocations`, `selectLocation`, `fitBounds`, and `destroy`.
Markers include accessible titles and never render unsanitized provider HTML.

- [ ] **Step 4: Implement nearby flow**

Geolocation runs only after user action. Success queries the nearby bridge;
denied/unavailable displays a non-blocking localized message. Radius is limited
to allowed values. Results update cards and map from one normalized collection.

- [ ] **Step 5: Run tests and build**

Run: `node tests/js/public-provider-map.test.js && php artisan test tests/Feature/PublicServiceProviderNearbyPageTest.php && npm run production`

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add resources/js/service-providers public/css/service-providers resources/views/service-providers webpack.mix.js public/js/service-providers tests
git commit -m "feat: search nearby providers on public map"
```

Choose **Mantener**.

### Task 6: Public location detail and contact actions

**Files:**
- Create: `resources/js/service-providers/08-location-detail.js`
- Create: `public/css/service-providers/04-location-detail.css`
- Modify: `resources/views/service-providers/show.blade.php`
- Modify: `resources/views/service-providers/partials/location-detail.blade.php`
- Modify: `webpack.mix.js`
- Create: `tests/Feature/PublicServiceProviderLocationDetailTest.php`
- Create: `tests/js/public-provider-detail.test.js`

- [ ] **Step 1: Write failing detail tests**

```php
public function test_detail_renders_public_operations_and_safe_contact_links(): void
{
    $locationId = '11111111-1111-4111-8111-111111111111';
    Http::fake(["https://api.mycode.cl/api/v1/public/service-locations/{$locationId}" => Http::response([
        'data' => $this->publicLocationFixture(),
    ])]);

    $this->get("/prestadores/{$locationId}")
        ->assertOk()
        ->assertSee('Consulta general')
        ->assertSee('href="tel:+56220000000"', false)
        ->assertSee('https://wa.me/56990000000', false)
        ->assertDontSee('private@example.com');
}
```

Add tests for schedules, exceptions, open state, 24 hours, emergency, home
service, images, verification badge, and unsafe URL schemes.

- [ ] **Step 2: Run tests**

Run: `php artisan test tests/Feature/PublicServiceProviderLocationDetailTest.php && node tests/js/public-provider-detail.test.js`

Expected: FAIL.

- [ ] **Step 3: Implement normalized contact links**

Only public contacts render. Phone links contain digits and optional leading
plus; WhatsApp links use digits only; website/social links allow only HTTPS.
Navigation opens Google Maps directions from the public coordinates.

- [ ] **Step 4: Implement detail sections**

Render provider header, branch address, verification, open state, schedules,
exceptions, services with optional prices, specialties, features, gallery,
contacts, and map. Missing sections are omitted rather than rendered empty.

- [ ] **Step 5: Run tests and build**

Run: `php artisan test tests/Feature/PublicServiceProviderLocationDetailTest.php && node tests/js/public-provider-detail.test.js && npm run production`

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add resources/js/service-providers public/css/service-providers resources/views/service-providers webpack.mix.js public/js/service-providers tests
git commit -m "feat: render public provider location details"
```

Choose **Mantener**.

### Task 7: Remove hardcoded Maps keys and add graceful degradation

**Files:**
- Modify: `resources/views/partials/public-profile/people/scripts-location.blade.php`
- Modify: `resources/views/partials/public-profile/pet/scripts-location.blade.php`
- Create: `app/Presentation/ViewModels/GoogleMapsConfig.php`
- Modify: `app/Application/Profile/ProfilePageRenderer.php`
- Modify: `config/services.php`
- Modify: `.env.example`
- Create: `tests/Feature/GoogleMapsConfigurationTest.php`

- [ ] **Step 1: Write failing secret/config tests**

```php
public function test_google_maps_key_is_loaded_from_environment_only(): void
{
    config()->set('services.google_maps.api_key', 'configured-test-key');

    $this->get('/prestadores')->assertOk()->assertSee('configured-test-key');

    $sources = collect(File::allFiles(resource_path()))
        ->map(fn (SplFileInfo $file) => $file->getContents())
        ->implode("\n");

    self::assertStringNotContainsString('AIza', $sources);
}
```

Add tests showing public profile and provider pages still render without a key.

- [ ] **Step 2: Run tests**

Run: `php artisan test tests/Feature/GoogleMapsConfigurationTest.php`

Expected: FAIL while the existing key remains hardcoded.

- [ ] **Step 3: Centralize map configuration**

`GoogleMapsConfig` exposes `apiKey()` and `available()`. Controllers/ViewModels
pass the key only to pages that load maps. Existing profile scripts use the
configured key and do not create the SDK tag when empty.

- [ ] **Step 4: Verify repository secret scan**

Run:

```bash
rg -n "AIza[0-9A-Za-z_-]{20,}" app config resources routes tests .env.example
php artisan test tests/Feature/GoogleMapsConfigurationTest.php
```

Expected: `rg` returns no matches and tests PASS.

- [ ] **Step 5: Commit**

```bash
git add app/Presentation/ViewModels/GoogleMapsConfig.php app/Application/Profile/ProfilePageRenderer.php config/services.php .env.example resources/views/partials/public-profile tests/Feature/GoogleMapsConfigurationTest.php
git commit -m "security: configure google maps key from environment"
```

Choose **Mantener**.

### Task 8: Localization, SEO, sitemap, accessibility, and final version

**Files:**
- Create: `resources/lang/es/service_providers.php`
- Create: `resources/lang/en/service_providers.php`
- Modify: `app/Presentation/ViewModels/PublicProviderViewModelFactory.php`
- Modify: `app/Presentation/Http/Controllers/Web/SitemapController.php`
- Modify: `tests/Feature/PublicPagesAvailabilityTest.php`
- Create: `tests/Feature/PublicServiceProviderLocalizationTest.php`
- Create: `tests/Feature/PublicServiceProviderSeoTest.php`
- Create: `docs/service-provider-public-directory.md`
- Modify: `VERSION`

- [ ] **Step 1: Write failing localization and SEO tests**

```php
public function test_directory_is_localized_in_spanish_and_english(): void
{
    Http::fake(['*' => Http::response(['data' => [], 'links' => [], 'meta' => []])]);

    $this->withSession(['locale' => 'es'])->get('/prestadores')
        ->assertSee('Prestadores de servicios');

    $this->withSession(['locale' => 'en'])->get('/prestadores')
        ->assertSee('Service providers');
}

public function test_sitemap_contains_provider_directory_but_not_private_portal(): void
{
    $this->get('/sitemap.xml')
        ->assertSee('/prestadores')
        ->assertDontSee('/mi-prestador');
}
```

Add canonical, metadata, JSON-LD LocalBusiness, keyboard labels, and noindex
assertions for account/admin pages.

- [ ] **Step 2: Run tests**

Run: `php artisan test tests/Feature/PublicServiceProviderLocalizationTest.php tests/Feature/PublicServiceProviderSeoTest.php tests/Feature/PublicPagesAvailabilityTest.php`

Expected: FAIL.

- [ ] **Step 3: Add complete translation catalogs**

Both locale files contain matching keys for filters, states, actions, empty
states, errors, map, distance, schedules, contacts, accessibility labels, and
provider types. Tests compare recursive key sets.

- [ ] **Step 4: Add SEO and structured data**

Directory canonical URLs omit ephemeral pagination when appropriate. Detail
uses provider/location title, description, canonical URL, social metadata, and
schema.org `LocalBusiness` JSON-LD containing only public fields.

- [ ] **Step 5: Update sitemap and documentation**

Add only the public directory root to the static sitemap. Dynamic detail URLs
remain discoverable through directory links. Document public routes, filters,
map fallback, cache behavior, and smoke tests.

- [ ] **Step 6: Run the entire project gate before versioning**

Run:

```bash
php artisan test
npm run test:admin-dashboard
npm run test:admin-users-status
npm run test:provider
node tests/js/admin-service-providers-list.test.js
node tests/js/admin-service-provider-detail.test.js
node tests/js/admin-provider-verification.test.js
node tests/js/admin-service-catalogs.test.js
node tests/js/public-provider-filters.test.js
node tests/js/public-provider-map.test.js
node tests/js/public-provider-detail.test.js
npm run production
php artisan architecture:audit --strict
rg -n "AIza[0-9A-Za-z_-]{20,}" app config resources routes tests .env.example
git diff --check
```

Expected: all tests and builds PASS; the secret scan returns no matches.

- [ ] **Step 7: Run browser acceptance for all three surfaces**

Verify:

1. provider portal complete flow;
2. admin moderation and catalogs;
3. public filters and pagination;
4. geolocation success, denial, and unsupported browser;
5. synchronized cards and markers;
6. public location detail/contact links;
7. Spanish and English;
8. mobile and desktop responsive layouts;
9. no JWT in DOM/storage/responses;
10. no regressions on people/pet profile maps.

- [ ] **Step 8: Change the release version**

Update `VERSION` exactly:

```text
1.2.0
```

Run: `php artisan test --filter=PublicPagesAvailabilityTest`

Expected: PASS and rendered footer/version marker shows `1.2.0`.

- [ ] **Step 9: Commit the complete verified release**

```bash
git add resources/lang app/Presentation app/Application resources/views tests docs VERSION public/js public/css webpack.mix.js package.json package-lock.json config .env.example routes
git commit -m "feat: release service provider network web"
```

At the hook, enter **1.2.0** or choose **Mantener** if `VERSION` is already
staged as `1.2.0`. Confirm the resulting commit contains `VERSION=1.2.0`.
