# Provider Portal State Alerts 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:** Show persistent, bilingual provider-state alerts and a Review navigation badge while clearly separating publication review from optional document verification.

**Architecture:** The API adds the verification rejection reason only to the authenticated owner resource. The web projects that field, derives normalized alerts with a pure JavaScript function, and renders them from the existing `providerStore` in the shared application shell; no notification persistence or new endpoint is introduced.

**Tech Stack:** Laravel/PHP 8.4, PostgreSQL, vanilla JavaScript, Blade, CSS, Node test harness, Laravel Mix.

---

## File structure

### `api_mycode`

- Modify `app/Http/Resources/ServiceProviders/ServiceVerificationResource.php`: expose the nullable rejection reason in the authenticated owner resource.
- Modify `tests/Feature/ServiceProviders/OwnerServiceProviderApiTest.php`: prove owners receive the reason and not private document fields.
- Reuse `tests/Feature/ServiceProviders/ServiceProviderResourcePrivacyTest.php`: retain the public privacy regression gate.

### `web_mycode`

- Modify `resources/js/provider/19-verifications-contract.js`: safely project the new field.
- Create `resources/js/provider/27-state-alert-contract.js`: pure state-to-alert derivation.
- Create `resources/js/provider/27-state-alert-view.js`: safe DOM rendering and badge updates.
- Create `resources/js/provider/27-state-alert-viewmodel.js`: subscribe the view to `providerStore`.
- Modify `resources/views/provider/partials/app-shell.blade.php`: add the global alert region and Review badge host.
- Modify `resources/views/provider/partials/verifications.blade.php`: add the publication-review versus verification distinction.
- Modify `resources/lang/es/provider_portal.php` and `resources/lang/en/provider_portal.php`: server-rendered bilingual copy.
- Modify `resources/js/provider/00-i18n.js`: dynamic bilingual alert copy.
- Modify `public/css/provider/portal/03-shell.css`, `04-components.css`, and `06-responsive.css`: desktop/mobile badge and alert presentation.
- Modify `webpack.mix.js`: compile the three new provider modules.
- Create `tests/js/provider-state-alerts.test.js`: model and renderer behavior.
- Modify `tests/js/provider-review.test.js`, `tests/js/provider-i18n.test.js`, `tests/Feature/ProviderPortalVisualContractTest.php`, and `tests/Feature/ProviderPortalLocalizationTest.php`: contract, copy, markup, and responsive regressions.
- Modify `package.json`: add the new JavaScript suite to `test:provider`.
- Rebuild `public/js/provider/app.js` and update `VERSION` through the repository hook after evaluating the real diff.

## Task 1: Expose the rejection reason to the authenticated provider owner

**Files:**
- Modify: `api_mycode/tests/Feature/ServiceProviders/OwnerServiceProviderApiTest.php`
- Modify: `api_mycode/app/Http/Resources/ServiceProviders/ServiceVerificationResource.php`

- [ ] **Step 1: Write the failing owner-resource test**

Add the enum imports and this test to `OwnerServiceProviderApiTest`:

```php
use App\Enums\ServiceProviders\VerificationStatus;
use App\Enums\ServiceProviders\VerificationType;

public function test_owner_sees_verification_rejection_reason_without_private_document_fields(): void
{
    $owner = User::factory()->create();
    $provider = ServiceProvider::factory()->for($owner, 'owner')->create();
    $provider->verifications()->create([
        'verification_type' => VerificationType::Business,
        'status' => VerificationStatus::Rejected,
        'document_path' => "service-providers/{$provider->id}/verifications/private.pdf",
        'metadata' => ['tax_id' => 'private-value'],
        'rejection_reason' => 'El documento está vencido.',
    ]);

    $token = $this->authenticate($owner);

    $this->withToken($token)
        ->getJson('/api/v1/service-providers/mine')
        ->assertOk()
        ->assertJsonPath(
            'data.0.verifications.0.rejection_reason',
            'El documento está vencido.'
        )
        ->assertJsonMissingPath('data.0.verifications.0.document_path')
        ->assertJsonMissingPath('data.0.verifications.0.metadata');
}
```

- [ ] **Step 2: Run the focused test and confirm the red state**

Run:

```bash
php artisan test --filter=test_owner_sees_verification_rejection_reason_without_private_document_fields
```

Expected: FAIL because `data.0.verifications.0.rejection_reason` is absent.

- [ ] **Step 3: Add the minimal private resource field**

Add one key to `ServiceVerificationResource::toArray()`:

```php
return [
    'id' => $this->id,
    'verification_type' => $this->verification_type?->value,
    'status' => $this->status?->value,
    'rejection_reason' => $this->rejection_reason,
    'reviewed_at' => $this->reviewed_at?->toIso8601String(),
    'expires_at' => $this->expires_at?->toIso8601String(),
    'evidences' => ServiceVerificationEvidenceResource::collection(
        $this->whenLoaded('evidences')
    ),
];
```

- [ ] **Step 4: Run private and public contract tests**

Run:

```bash
php artisan test tests/Feature/ServiceProviders/OwnerServiceProviderApiTest.php
php artisan test tests/Feature/ServiceProviders/ServiceProviderResourcePrivacyTest.php
```

Expected: both files PASS; public resources still omit `rejection_reason` and `verifications`.

- [ ] **Step 5: Commit the API contract change**

```bash
git add app/Http/Resources/ServiceProviders/ServiceVerificationResource.php \
  tests/Feature/ServiceProviders/OwnerServiceProviderApiTest.php
git commit -m "feat: expose provider verification rejection reasons"
```

## Task 2: Project and validate the private reason in the web contract

**Files:**
- Modify: `web_mycode/tests/js/provider-review.test.js`
- Modify: `web_mycode/resources/js/provider/19-verifications-contract.js`

- [ ] **Step 1: Add failing projection tests**

Extend the verification contract section of `provider-review.test.js`:

```javascript
const rejectedVerification = context.providerProjectVerification({
    id: verificationId,
    verification_type: 'business',
    status: 'rejected',
    rejection_reason: 'Documento ilegible',
    reviewed_at: null,
    expires_at: null,
    evidences: [],
});
assert.strictEqual(rejectedVerification.rejection_reason, 'Documento ilegible');
assert.throws(
    () => context.providerProjectVerification({
        id: verificationId,
        verification_type: 'business',
        status: 'rejected',
        rejection_reason: { unsafe: true },
        reviewed_at: null,
        expires_at: null,
        evidences: [],
    }),
    /No fue posible validar la respuesta del servicio/,
);
```

- [ ] **Step 2: Run the JavaScript test and confirm failure**

```bash
node tests/js/provider-review.test.js
```

Expected: FAIL because the projected record has no `rejection_reason` and malformed input is accepted.

- [ ] **Step 3: Add strict nullable-text projection**

Add this helper and field to `19-verifications-contract.js`:

```javascript
function providerOptionalVerificationReason(value) {
    if (value === null || value === undefined) return null;
    if (typeof value !== 'string') throw providerContractError();
    return value.slice(0, 2000);
}

function providerProjectVerification(resource) {
    if (!providerIsRecord(resource)) throw providerContractError();
    const id = providerCanonicalUuid(resource.id);
    if (!id || !providerVerificationRecordTypes.includes(resource.verification_type)
        || !providerVerificationStatuses.includes(resource.status)) {
        throw providerContractError();
    }
    return {
        id,
        verification_type: resource.verification_type,
        status: resource.status,
        rejection_reason: providerOptionalVerificationReason(resource.rejection_reason),
        reviewed_at: providerOptionalIsoDate(resource.reviewed_at),
        expires_at: providerOptionalIsoDate(resource.expires_at),
        evidences: Array.isArray(resource.evidences)
            ? resource.evidences.map(providerProjectVerificationEvidence)
            : [],
    };
}
```

- [ ] **Step 4: Re-run the focused web contract test**

```bash
node tests/js/provider-review.test.js
```

Expected: `provider-review.test.js: PASS`.

- [ ] **Step 5: Commit the web contract**

```bash
git add resources/js/provider/19-verifications-contract.js tests/js/provider-review.test.js
git commit -m "feat: project verification rejection reasons"
```

When the version hook opens, keep `1.9.0` at this intermediate contract-only commit; version the complete user-facing feature in Task 6.

## Task 3: Derive alerts with a pure model

**Files:**
- Create: `web_mycode/resources/js/provider/27-state-alert-contract.js`
- Create: `web_mycode/tests/js/provider-state-alerts.test.js`
- Modify: `web_mycode/package.json`

- [ ] **Step 1: Write failing state-matrix tests**

Create a Node test harness that loads `00-i18n.js` and the new contract, then assert this matrix:

```javascript
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const vm = require('vm');

const root = path.resolve(__dirname, '..', '..');
const context = { providerConfig: { locale: 'es' } };
vm.createContext(context);
for (const source of [
    'resources/js/provider/00-i18n.js',
    'resources/js/provider/27-state-alert-contract.js',
]) {
    vm.runInContext(fs.readFileSync(path.join(root, source), 'utf8'), context);
}

const routes = { reviewUrl: '/mi-prestador/revision' };
const provider = (patch = {}) => ({
    id: '123e4567-e89b-42d3-a456-426614174000',
    status: 'draft',
    verifications: [],
    ...patch,
});

assert.deepStrictEqual(
    Array.from(context.providerDeriveStateAlerts(provider(), routes), alert => alert.key),
    ['verification-missing'],
);
assert.strictEqual(context.providerActionableAlertCount(
    context.providerDeriveStateAlerts(provider(), routes)
), 0);

const rejected = context.providerDeriveStateAlerts(provider({
    status: 'rejected',
    rejection_reason: '<b>Falta documento</b>',
    verifications: [{
        id: '123e4567-e89b-42d3-a456-426614174001',
        verification_type: 'business',
        status: 'rejected',
        rejection_reason: 'Documento vencido',
        evidences: [],
    }],
}), routes);
assert.deepStrictEqual(Array.from(rejected, alert => alert.key), [
    'provider-rejected',
    'verification-rejected:123e4567-e89b-42d3-a456-426614174001',
]);
assert.strictEqual(context.providerActionableAlertCount(rejected), 2);
assert.strictEqual(rejected[0].reason, '<b>Falta documento</b>');
assert.strictEqual(rejected[1].href, '/mi-prestador/revision#provider-verifications-heading');

const pending = context.providerDeriveStateAlerts(provider({ status: 'pending_review' }), routes);
assert.deepStrictEqual(Array.from(pending, alert => alert.key), ['provider-pending-review']);
assert.strictEqual(context.providerActionableAlertCount(pending), 0);

assert.deepStrictEqual(
    Array.from(context.providerDeriveStateAlerts(provider({ status: 'published' }), routes)),
    [],
);
assert.deepStrictEqual(
    Array.from(context.providerDeriveStateAlerts(provider({ status: 'archived' }), routes)),
    [],
);

console.log('provider-state-alerts.test.js: PASS');
```

- [ ] **Step 2: Run the test and confirm the missing-module failure**

```bash
node tests/js/provider-state-alerts.test.js
```

Expected: FAIL because `27-state-alert-contract.js` does not exist.

- [ ] **Step 3: Implement deterministic alert descriptors**

Create `27-state-alert-contract.js` with:

```javascript
function providerStateAlert(data) {
    return Object.freeze({
        key: data.key,
        priority: data.priority,
        tone: data.tone,
        actionable: data.actionable,
        title: data.title,
        message: data.message,
        reason: typeof data.reason === 'string' ? data.reason : '',
        href: data.href,
        action: data.action,
    });
}

function providerDeriveStateAlerts(provider, routes) {
    if (!provider || typeof routes?.reviewUrl !== 'string') return [];
    const reviewUrl = routes.reviewUrl;
    const alerts = [];

    if (provider.status === 'suspended') {
        alerts.push(providerStateAlert({
            key: 'provider-suspended', priority: 500, tone: 'action', actionable: true,
            title: 'Tu perfil está suspendido.',
            message: 'Revisa el motivo y contacta a MyCode para solicitar cambios.',
            reason: provider.suspension_reason,
            href: reviewUrl, action: 'Ver estado',
        }));
    }
    if (provider.status === 'rejected') {
        alerts.push(providerStateAlert({
            key: 'provider-rejected', priority: 400, tone: 'action', actionable: true,
            title: 'Tu perfil requiere cambios.',
            message: 'Corrige la información indicada antes de volver a enviarlo.',
            reason: provider.rejection_reason,
            href: reviewUrl, action: 'Revisar perfil',
        }));
    }

    (provider.verifications || [])
        .filter((verification) => verification.status === 'rejected')
        .forEach((verification) => alerts.push(providerStateAlert({
            key: `verification-rejected:${verification.id}`,
            priority: 300, tone: 'action', actionable: true,
            title: 'Una verificación fue rechazada.',
            message: 'Revisa el motivo y reemplaza la evidencia antes de reenviarla.',
            reason: verification.rejection_reason,
            href: `${reviewUrl}#provider-verifications-heading`,
            action: 'Revisar verificación',
        })));

    if (provider.status === 'pending_review') {
        alerts.push(providerStateAlert({
            key: 'provider-pending-review', priority: 100, tone: 'info', actionable: false,
            title: 'Tu perfil está en revisión.',
            message: 'La edición permanecerá bloqueada hasta que finalice la revisión.',
            href: reviewUrl, action: 'Ver estado',
        }));
    }
    if (['draft', 'rejected'].includes(provider.status)
        && (provider.verifications || []).length === 0) {
        alerts.push(providerStateAlert({
            key: 'verification-missing', priority: 50, tone: 'info', actionable: false,
            title: 'La verificación documental es un proceso separado.',
            message: 'Enviar el perfil no adjunta documentos ni solicita una verificación.',
            href: `${reviewUrl}#provider-verifications-heading`,
            action: 'Agregar evidencia',
        }));
    }

    return alerts.sort((left, right) => right.priority - left.priority
        || left.key.localeCompare(right.key));
}

function providerActionableAlertCount(alerts) {
    return alerts.filter((alert) => alert.actionable).length;
}
```

- [ ] **Step 4: Add the suite to `test:provider` and run it**

Append `&& node tests/js/provider-state-alerts.test.js` to the `test:provider` script, then run:

```bash
npm run test:provider
```

Expected: every provider Node test prints PASS.

- [ ] **Step 5: Commit the pure alert model**

```bash
git add package.json resources/js/provider/27-state-alert-contract.js \
  tests/js/provider-state-alerts.test.js
git commit -m "feat: derive provider portal state alerts"
```

Keep `1.9.0` when the version hook opens; the complete visible feature is versioned in Task 6.

## Task 4: Render the global banner and Review badge

**Files:**
- Create: `web_mycode/resources/js/provider/27-state-alert-view.js`
- Create: `web_mycode/resources/js/provider/27-state-alert-viewmodel.js`
- Modify: `web_mycode/resources/views/provider/partials/app-shell.blade.php`
- Modify: `web_mycode/public/css/provider/portal/03-shell.css`
- Modify: `web_mycode/public/css/provider/portal/04-components.css`
- Modify: `web_mycode/public/css/provider/portal/06-responsive.css`
- Modify: `web_mycode/webpack.mix.js`
- Modify: `web_mycode/tests/js/provider-state-alerts.test.js`
- Modify: `web_mycode/tests/Feature/ProviderPortalVisualContractTest.php`

- [ ] **Step 1: Add failing markup and rendering assertions**

Add feature assertions for:

```php
$html = $this->withProviderSession()
    ->get('/mi-prestador')
    ->assertOk()
    ->getContent();

$this->assertStringContainsString('id="provider-state-alerts"', $html);
$this->assertStringContainsString('id="provider-review-alert-badge"', $html);
$this->assertStringContainsString('aria-live="polite"', $html);
```

Add `resources/js/provider/27-state-alert-view.js` to the Node test's ordered `source` list immediately after the contract. Then extend the test with this fake document and assertions:

```javascript
function testNode(tagName = 'div') {
    return {
        tagName,
        children: [],
        dataset: {},
        hidden: false,
        textContent: '',
        append(...children) { this.children.push(...children); },
        replaceChildren(...children) { this.children = children; },
        setAttribute(name, value) { this[name] = value; },
        removeAttribute(name) { delete this[name]; },
    };
}
const alertHost = testNode('section');
const alertBadge = testNode('span');
const documentStub = {
    createElement: testNode,
    querySelector(selector) {
        if (selector === '#provider-state-alerts') return alertHost;
        if (selector === '#provider-review-alert-badge') return alertBadge;
        return null;
    },
};

context.providerRenderStateAlerts(rejected, documentStub);
assert.strictEqual(alertHost.hidden, false);
assert.strictEqual(alertHost.children.length, 2);
const renderedReason = alertHost.children[0].children[1].children[2];
assert.strictEqual(renderedReason.textContent, '<b>Falta documento</b>');
assert.strictEqual(renderedReason.innerHTML, undefined);
assert.strictEqual(alertBadge.textContent, '2');
assert.strictEqual(alertBadge.hidden, false);

context.providerRenderStateAlerts(pending, documentStub);
assert.strictEqual(alertBadge.hidden, true);
```

- [ ] **Step 2: Run the focused tests and confirm failure**

```bash
node tests/js/provider-state-alerts.test.js
php artisan test tests/Feature/ProviderPortalVisualContractTest.php
```

Expected: FAIL because the renderer and shell hosts do not exist.

- [ ] **Step 3: Add semantic hosts to the shared shell**

Inside the Review navigation link, wrap its label and add:

```blade
<span>{{ __('provider_portal.shell.review') }}</span>
<span id="provider-review-alert-badge" class="provider-nav__alert-badge"
    aria-live="polite" hidden></span>
```

Inside `.provider-main`, before `provider-app-feedback`, add:

```blade
<section id="provider-state-alerts" class="provider-state-alerts"
    aria-label="{{ __('provider_portal.alerts.region') }}"
    aria-live="polite" hidden></section>
```

- [ ] **Step 4: Implement safe DOM rendering**

Create `27-state-alert-view.js`:

```javascript
function providerAlertCountLabel(count) {
    const pattern = count === 1 ? '1 alerta pendiente' : ':count alertas pendientes';
    return providerTranslate(pattern).replace(':count', String(count));
}

function providerStateAlertCard(alert, doc = document) {
    const card = doc.createElement('article');
    card.className = `provider-state-alert provider-state-alert--${alert.tone}`;
    card.dataset.alertKey = alert.key;
    card.setAttribute('role', alert.actionable ? 'alert' : 'status');

    const icon = doc.createElement('span');
    icon.className = 'material-symbols-rounded provider-state-alert__icon';
    icon.setAttribute('aria-hidden', 'true');
    icon.textContent = alert.actionable ? 'error' : 'info';

    const copy = doc.createElement('div');
    copy.className = 'provider-state-alert__copy';
    const title = doc.createElement('h2');
    title.textContent = providerTranslate(alert.title);
    const message = doc.createElement('p');
    message.textContent = providerTranslate(alert.message);
    copy.append(title, message);

    if (alert.reason) {
        const reason = doc.createElement('p');
        reason.className = 'provider-state-alert__reason';
        reason.textContent = alert.reason;
        copy.append(reason);
    }

    const action = doc.createElement('a');
    action.className = 'provider-state-alert__action';
    action.href = alert.href;
    action.textContent = providerTranslate(alert.action);
    card.append(icon, copy, action);
    return card;
}

function providerRenderStateAlerts(alerts, doc = document) {
    const host = doc.querySelector('#provider-state-alerts');
    const badge = doc.querySelector('#provider-review-alert-badge');
    if (!host || !badge) return;

    const cards = alerts.map((alert) => providerStateAlertCard(alert, doc));
    host.replaceChildren(...cards);
    host.hidden = cards.length === 0;

    const count = providerActionableAlertCount(alerts);
    badge.hidden = count === 0;
    badge.textContent = count ? String(count) : '';
    if (count) badge.setAttribute('aria-label', providerAlertCountLabel(count));
    else badge.removeAttribute('aria-label');
}
```

- [ ] **Step 5: Subscribe rendering to the existing store**

Create `27-state-alert-viewmodel.js`:

```javascript
var providerStateAlertUnsubscribe = null;

function providerRenderStateAlertsFromStore(state) {
    const alerts = providerDeriveStateAlerts(state.provider, {
        reviewUrl: providerConfig.reviewUrl,
    });
    providerRenderStateAlerts(alerts);
}

function providerInitializeStateAlerts() {
    if (!providerConfig || !['dashboard', 'profile', 'locations', 'review', 'resolutions']
        .includes(providerConfig.screen)) return;

    providerStateAlertUnsubscribe?.();
    providerStateAlertUnsubscribe = providerStore.subscribe(providerRenderStateAlertsFromStore);
    providerRenderStateAlertsFromStore(providerStore.get());
}

if (typeof document !== 'undefined') {
    document.addEventListener('DOMContentLoaded', providerInitializeStateAlerts);
}
```

- [ ] **Step 6: Compile the modules in dependency order**

Append these after `26-resolution-init.js` in `webpack.mix.js`:

```javascript
'resources/js/provider/27-state-alert-contract.js',
'resources/js/provider/27-state-alert-view.js',
'resources/js/provider/27-state-alert-viewmodel.js',
```

- [ ] **Step 7: Add MyCode desktop/mobile styling**

Add the following contracts, using existing tokens:

```css
.provider-nav a { position: relative; }
.provider-nav__alert-badge {
    display: grid; place-items: center; min-width: 1.35rem; height: 1.35rem;
    margin-left: auto; padding: 0 .35rem; border-radius: 999px;
    color: #fff; background: var(--mc-accent); font-size: .68rem; font-weight: 800;
}

.provider-state-alerts { display: grid; gap: .75rem; margin-bottom: 1.25rem; }
.provider-state-alert {
    display: grid; grid-template-columns: auto minmax(0, 1fr) auto;
    align-items: center; gap: 1rem; padding: 1rem 1.15rem;
    border: 1px solid var(--mc-line); border-left-width: .25rem;
    border-radius: var(--mc-radius-md); background: var(--mc-surface);
}
.provider-state-alert--action { border-left-color: var(--mc-accent); background: var(--mc-accent-soft); }
.provider-state-alert--info { border-left-color: var(--mc-primary); background: var(--mc-primary-soft); }
.provider-state-alert__copy h2 { margin: 0; font-size: 1rem; }
.provider-state-alert__copy p { margin: .2rem 0 0; }
.provider-state-alert__reason { font-weight: 700; }
.provider-state-alert__action { white-space: nowrap; font-weight: 800; }
```

Add these exact responsive and motion contracts:

```css
.provider-state-alert { animation: provider-state-alert-enter .18s ease-out; }
@keyframes provider-state-alert-enter {
    from { opacity: 0; transform: translateY(-.25rem); }
    to { opacity: 1; transform: translateY(0); }
}

@media (max-width: 760px) {
    .provider-nav__alert-badge {
        position: absolute; top: .2rem; right: calc(50% - 1.25rem);
        min-width: 1rem; height: 1rem; padding: 0 .2rem; font-size: .55rem;
    }
    .provider-state-alert { grid-template-columns: auto minmax(0, 1fr); }
    .provider-state-alert__action { grid-column: 2; justify-self: start; }
}

@media (prefers-reduced-motion: reduce) {
    .provider-state-alert { animation: none; }
}
```

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

```bash
node tests/js/provider-state-alerts.test.js
php artisan test tests/Feature/ProviderPortalVisualContractTest.php
```

Expected: PASS.

- [ ] **Step 9: Commit the shared alert UI**

```bash
git add webpack.mix.js resources/js/provider/27-state-alert-view.js \
  resources/js/provider/27-state-alert-viewmodel.js \
  resources/views/provider/partials/app-shell.blade.php \
  public/css/provider/portal/03-shell.css \
  public/css/provider/portal/04-components.css \
  public/css/provider/portal/06-responsive.css \
  tests/js/provider-state-alerts.test.js \
  tests/Feature/ProviderPortalVisualContractTest.php
git commit -m "feat: render provider portal state alerts"
```

Keep `1.9.0` at this intermediate commit.

## Task 5: Add bilingual copy and distinguish review from verification

**Files:**
- Modify: `web_mycode/resources/lang/es/provider_portal.php`
- Modify: `web_mycode/resources/lang/en/provider_portal.php`
- Modify: `web_mycode/resources/js/provider/00-i18n.js`
- Modify: `web_mycode/resources/views/provider/partials/verifications.blade.php`
- Modify: `web_mycode/public/css/provider/portal/04-components.css`
- Modify: `web_mycode/tests/js/provider-i18n.test.js`
- Modify: `web_mycode/tests/Feature/ProviderPortalLocalizationTest.php`

- [ ] **Step 1: Write failing Spanish and English page assertions**

Add assertions for both locales:

```php
$this->withProviderSessionLocale('es')
    ->get('/mi-prestador/revision')
    ->assertOk()
    ->assertSee('Revisión y verificación son procesos distintos')
    ->assertSee('Enviar el perfil a revisión no envía documentos ni solicita una verificación.');

$this->withProviderSessionLocale('en')
    ->get('/mi-prestador/revision')
    ->assertOk()
    ->assertSee('Review and verification are separate processes')
    ->assertSee('Submitting the profile for review does not send documents or request verification.');
```

Add dynamic translation assertions:

```javascript
assert.strictEqual(
    context.providerTranslate('Tu perfil requiere cambios.'),
    'Your profile requires changes.',
);
assert.strictEqual(
    context.providerTranslate(':count alertas pendientes'),
    ':count pending alerts',
);
```

- [ ] **Step 2: Run localization tests and confirm failure**

```bash
node tests/js/provider-i18n.test.js
php artisan test tests/Feature/ProviderPortalLocalizationTest.php
```

Expected: FAIL because the new bilingual copy is missing.

- [ ] **Step 3: Add locale keys and dynamic translations**

Add the top-level `alerts` array immediately before `verifications`, and add the two distinction keys inside the existing `verifications` array:

```php
// Insert at the top level of resources/lang/es/provider_portal.php.
'alerts' => ['region' => 'Alertas del perfil'],
// Insert these two keys inside the existing Spanish 'verifications' array.
'review_distinction_title' => 'Revisión y verificación son procesos distintos',
'review_distinction' => 'Enviar el perfil a revisión no envía documentos ni solicita una verificación. Si deseas verificar tu identidad o comercio, adjunta la evidencia aquí antes de enviar el perfil.',

// Insert at the top level of resources/lang/en/provider_portal.php.
'alerts' => ['region' => 'Profile alerts'],
// Insert these two keys inside the existing English 'verifications' array.
'review_distinction_title' => 'Review and verification are separate processes',
'review_distinction' => 'Submitting the profile for review does not send documents or request verification. To verify your identity or business, attach the evidence here before submitting the profile.',
```

Add these exact entries to `providerEnglishText`:

```javascript
'Tu perfil está suspendido.': 'Your profile is suspended.',
'Revisa el motivo y contacta a MyCode para solicitar cambios.':
    'Review the reason and contact MyCode to request changes.',
'Ver estado': 'View status',
'Tu perfil requiere cambios.': 'Your profile requires changes.',
'Corrige la información indicada antes de volver a enviarlo.':
    'Correct the indicated information before submitting it again.',
'Revisar perfil': 'Review profile',
'Una verificación fue rechazada.': 'A verification was rejected.',
'Revisa el motivo y reemplaza la evidencia antes de reenviarla.':
    'Review the reason and replace the evidence before resubmitting it.',
'Revisar verificación': 'Review verification',
'Tu perfil está en revisión.': 'Your profile is under review.',
'La edición permanecerá bloqueada hasta que finalice la revisión.':
    'Editing will remain locked until the review is complete.',
'La verificación documental es un proceso separado.':
    'Document verification is a separate process.',
'Enviar el perfil no adjunta documentos ni solicita una verificación.':
    'Submitting the profile does not attach documents or request verification.',
'Agregar evidencia': 'Add evidence',
'1 alerta pendiente': '1 pending alert',
':count alertas pendientes': ':count pending alerts',
```

- [ ] **Step 4: Render the distinction beside verification controls**

In `verifications.blade.php`, before the existing optional requirements guide, add:

```blade
<aside class="provider-review-distinction" role="note">
    <span class="material-symbols-rounded" aria-hidden="true">info</span>
    <p>
        <strong>{{ __('provider_portal.verifications.review_distinction_title') }}</strong>
        {{ __('provider_portal.verifications.review_distinction') }}
    </p>
</aside>
```

Add this exact component styling:

```css
.provider-review-distinction {
    display: flex; align-items: flex-start; gap: .75rem;
    margin: 1rem 0; padding: 1rem 1.1rem;
    border: 1px solid var(--mc-primary-soft);
    border-left: .25rem solid var(--mc-accent);
    border-radius: var(--mc-radius-md);
    color: var(--mc-text); background: var(--mc-primary-soft);
}
.provider-review-distinction > span { color: var(--mc-primary); }
.provider-review-distinction p { margin: 0; line-height: 1.55; }
.provider-review-distinction strong { display: block; margin-bottom: .2rem; }
```

- [ ] **Step 5: Run all localization and review tests**

```bash
node tests/js/provider-i18n.test.js
node tests/js/provider-state-alerts.test.js
php artisan test tests/Feature/ProviderPortalLocalizationTest.php \
  tests/Feature/ProviderPortalReviewTest.php \
  tests/Feature/ProviderPortalVerificationUiTest.php
```

Expected: PASS; verification remains optional and review submission is not blocked.

- [ ] **Step 6: Commit bilingual behavior**

```bash
git add resources/lang/es/provider_portal.php resources/lang/en/provider_portal.php \
  resources/js/provider/00-i18n.js \
  resources/views/provider/partials/verifications.blade.php \
  public/css/provider/portal/04-components.css \
  tests/js/provider-i18n.test.js \
  tests/Feature/ProviderPortalLocalizationTest.php
git commit -m "feat: explain provider review and verification states"
```

Keep `1.9.0` at this intermediate commit.

## Task 6: Verify, build, version, and integrate

**Files:**
- Modify: `web_mycode/public/js/provider/app.js`
- Modify: `web_mycode/VERSION`
- Do not commit: `web_mycode/public/mix-manifest.json`
- Do not commit: `web_mycode/.phpunit.cache/test-results`

- [ ] **Step 1: Run the complete API verification gates**

```bash
cd /Users/tumac.cl/PhpstormProjects/api_mycode
php artisan test
```

Expected: all API tests PASS.

- [ ] **Step 2: Run the complete web verification gates**

```bash
cd /Users/tumac.cl/PhpstormProjects/web_mycode/.worktrees/provider-form-dirty-state
npm run test:provider
php artisan architecture:audit --strict
php artisan test
```

Expected: Node provider suite PASS, architecture audit PASS, and all Laravel tests PASS.

- [ ] **Step 3: Build the production asset**

```bash
npm ci
npm run production
rm -f public/mix-manifest.json
```

Expected: Mix completes successfully and `public/js/provider/app.js` contains `providerDeriveStateAlerts` and `providerRenderStateAlerts`.

- [ ] **Step 4: Re-run asset and focused tests against the build**

```bash
rg -n "providerDeriveStateAlerts|providerRenderStateAlerts" public/js/provider/app.js
npm run test:provider
php artisan test tests/Feature/ProviderPortalAssetVersioningTest.php \
  tests/Feature/ProviderPortalVisualContractTest.php \
  tests/Feature/ProviderPortalLocalizationTest.php
```

Expected: both functions are found and every test PASS.

- [ ] **Step 5: Evaluate the version hook and create the release commit**

The feature is backward compatible but user visible, so choose the minor increment `1.10.0` when the version dialog opens:

```bash
git add public/js/provider/app.js
git commit -m "build: compile provider state alert assets"
```

At the prompt `Nueva version web`, enter:

```text
1.10.0
```

Expected: the hook stages `VERSION`; the release commit contains `public/js/provider/app.js` and `VERSION`, but not `public/mix-manifest.json` or `.phpunit.cache/test-results`.

- [ ] **Step 6: Review both repository deltas**

Run `detect_changes` and `get_affected_flows` with the code-review graph in each repository, then run:

```bash
git status --short
git log --oneline --decorate -6
```

Expected: only the known pre-existing `.phpunit.cache/test-results` modification remains in the web worktree; no uncommitted API source changes remain.

- [ ] **Step 7: Push API first, then web**

Integrate only the reviewed feature commits into each local `master`. If the feature branch is a direct descendant, use `git merge --ff-only <feature-branch>`; otherwise use `git cherry-pick <reviewed-commit>...` with the exact reviewed hashes. Then run in each repository:

```bash
git push origin master
```

Expected: both remotes accept the updates. Record the two remote commit hashes for the deployment handoff.

## Task 7: Prepare and validate the droplet deployment handoff

**Files:**
- No repository files.

- [ ] **Step 1: Provide this API deployment block with the final expected commit hash**

```bash
bash <<'DEPLOY_API'
set -Eeuo pipefail

cd /var/www/services/apimycode

if test -n "$(git status --porcelain --untracked-files=all)"; then
    echo 'ERROR: apimycode contiene cambios locales:'
    git status --short
    exit 1
fi

API_PREVIOUS_HEAD="$(git rev-parse HEAD)"
echo "API rollback point: $API_PREVIOUS_HEAD"

php artisan down --retry=60
trap 'php artisan up' EXIT

git pull --ff-only origin master

composer install \
  --no-dev \
  --prefer-dist \
  --optimize-autoloader \
  --no-interaction

php artisan migrate --force
php artisan optimize:clear
php artisan config:cache
php artisan route:cache

chown -R www-data:www-data storage bootstrap/cache
chmod -R ug+rwX storage bootstrap/cache

apachectl configtest
systemctl reload apache2

php artisan up
trap - EXIT

git log -1 --oneline
php artisan route:list --path=service-providers
curl -fsS https://api.mycode.cl/service-status
printf '\n'
DEPLOY_API
```

- [ ] **Step 2: Provide this web deployment block with the final expected commit and version**

```bash
bash <<'DEPLOY_WEB'
set -Eeuo pipefail

cd /var/www/html/webmycode

WEB_STATUS="$(git status --porcelain --untracked-files=all)"
if [ "$WEB_STATUS" = "?? public/mix-manifest.json" ]; then
    rm -f public/mix-manifest.json
elif [ -n "$WEB_STATUS" ]; then
    echo 'ERROR: webmycode contiene cambios locales:'
    git status --short
    exit 1
fi

WEB_PREVIOUS_HEAD="$(git rev-parse HEAD)"
echo "Web rollback point: $WEB_PREVIOUS_HEAD"

php artisan down --retry=60
trap 'php artisan up' EXIT

git pull --ff-only origin master

composer install \
  --no-dev \
  --prefer-dist \
  --optimize-autoloader \
  --no-interaction

npm ci
npm run production
rm -f public/mix-manifest.json

php artisan optimize:clear
php artisan config:cache
php artisan route:cache
php artisan view:cache

chown -R www-data:www-data storage bootstrap/cache
chmod -R ug+rwX storage bootstrap/cache

apachectl configtest
systemctl reload apache2

php artisan up
trap - EXIT

git log -1 --oneline
cat VERSION
rg -n "providerDeriveStateAlerts|providerRenderStateAlerts" public/js/provider/app.js
curl -fsSI https://mycode.cl/mi-prestador/login | head
DEPLOY_WEB
```

- [ ] **Step 3: State deployment order and rollback evidence**

Tell the operator to deploy API first and web second. Before each pull, record `git rev-parse HEAD`; if validation fails, keep those hashes as explicit rollback points rather than running destructive Git commands.
