# G1/SP3a — фронт входа (регистрация + подтверждение почты) — 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:** Подключить фронт к новому backend-потоку самозаписи SP1 — регистрация → 6-значный код на почту → подтверждение → вход.

**Architecture:** Правка api-слоя (`api/auth.ts`) и Pinia-store (`stores/auth.ts`) под новые контракты; переработка `RegisterView.vue` (шов капчи, переход на подтверждение); новый `ConfirmEmailView.vue`; роут `/confirm-email`.

**Tech Stack:** Vue 3 (`<script setup lang="ts">`) + Vuetify 3 + Pinia + vue-router. Стиль — `AuthLayout`/Forest.

**Спека:** `docs/superpowers/specs/2026-06-18-g1-sp3a-registration-confirm-frontend-spec-v1.md`.

**⚠ Проверка — Playwright, НЕ vitest** (vitest сломан, находка G8). TDD-цикл с unit-тестами не применяется на фронте; вместо «watch it fail/pass» — живой прогон в браузере (Task 6). Это сознательное отступление по указанию владельца (G8 + выбор Playwright); user-instruction приоритетнее TDD-навыка.

**Режим:** реализация под стеной через per-step escape. Финал (build + lint + commit) — терминал владельца.

**Сборка/проверка фронта:** dev-сервер `npm --prefix app run dev` (Vite) или `npm --prefix app run build`; backend `php app/artisan serve` на `127.0.0.1:8000`. Линт: `npm --prefix app run lint:vue`, типы: `npm --prefix app run type-check`.

---

## Карта файлов

- Изменить: `app/resources/js/api/auth.ts` (контракты register/confirm/resend)
- Изменить: `app/resources/js/stores/auth.ts` (actions + pendingEmail/pendingDevCode)
- Изменить: `app/resources/js/router/index.ts` (роут `/confirm-email`)
- Изменить: `app/resources/js/views/auth/RegisterView.vue` (капча-шов + переход)
- Создать: `app/resources/js/views/auth/ConfirmEmailView.vue`

---

## Task 1: api/auth.ts — контракты register / confirmEmail / resendCode

**Files:** Modify `app/resources/js/api/auth.ts`

- [ ] **Step 1: Обновить `RegisterPayload` (добавить `captcha_token`)** — заменить интерфейс:

```ts
export interface RegisterPayload {
    email: string;
    password: string;
    accept_offer: boolean;
    accept_pdn: boolean;
    captcha_token: string;
}
```

- [ ] **Step 2: Добавить `RegisterResult` + переписать `register()`** — заменить существующую функцию `register` и добавить тип перед ней:

```ts
export interface RegisterResult {
    status: string;
    email: string;
    expires_at: string;
    _dev_plain_code?: string;
}

export async function register(payload: RegisterPayload): Promise<RegisterResult> {
    await ensureCsrfCookie();
    const { data } = await apiClient.post<RegisterResult>('/api/auth/register', payload);
    return data;
}
```

- [ ] **Step 3: Добавить `confirmEmail()` и `resendCode()`** — дописать после `register`:

```ts
export interface ConfirmEmailPayload {
    email: string;
    code: string;
}

export async function confirmEmail(payload: ConfirmEmailPayload): Promise<{ user: AuthUser }> {
    await ensureCsrfCookie();
    const { data } = await apiClient.post<{ user: AuthUser }>('/api/auth/confirm-email', payload);
    return data;
}

export interface ResendCodeResult {
    message: string;
    _dev_plain_code?: string;
}

export async function resendCode(email: string): Promise<ResendCodeResult> {
    await ensureCsrfCookie();
    const { data } = await apiClient.post<ResendCodeResult>('/api/auth/resend-code', { email });
    return data;
}
```

- [ ] **Step 4: Проверка типов** — `npm --prefix app run type-check` → 0 ошибок в `api/auth.ts` (store ещё ссылается на старый return — это ок до Task 2; если type-check ругается на store, продолжаем Task 2).

---

## Task 2: stores/auth.ts — actions + pendingEmail/pendingDevCode

**Files:** Modify `app/resources/js/stores/auth.ts`

- [ ] **Step 1: Импорт типов** — заменить строку импорта типов на:

```ts
import type { AuthUser, LoginPayload, RegisterPayload, RegisterResult, ResetPasswordPayload } from '../api/auth';
```

- [ ] **Step 2: Добавить ref-поля** — после `const lockoutSeconds = ref<number | null>(null);` добавить:

```ts
    /** G1/SP3a: email/код самозаписи между экраном регистрации и подтверждения. */
    const pendingEmail = ref<string | null>(null);
    const pendingDevCode = ref<string | null>(null);
```

- [ ] **Step 3: Переписать `register()`** — заменить тело функции `register`:

```ts
    async function register(payload: RegisterPayload): Promise<RegisterResult> {
        loading.value = true;
        lockoutSeconds.value = null;
        try {
            const result = await authApi.register(payload);
            // Новый поток SP1: user НЕ ставится — сессия создаётся при confirm-email.
            pendingEmail.value = result.email;
            pendingDevCode.value = result._dev_plain_code ?? null;
            return result;
        } catch (error) {
            const retry = extractRateLimitRetry(error);
            if (retry !== null) lockoutSeconds.value = retry;
            throw error;
        } finally {
            loading.value = false;
        }
    }
```

- [ ] **Step 4: Добавить `confirmEmail()` и `resendCode()`** — после `register`:

```ts
    async function confirmEmail(payload: { email: string; code: string }) {
        loading.value = true;
        try {
            const response = await authApi.confirmEmail(payload);
            user.value = response.user;
            requires2fa.value = false;
            pendingEmail.value = null;
            pendingDevCode.value = null;
            return response;
        } finally {
            loading.value = false;
        }
    }

    async function resendCode(email: string) {
        loading.value = true;
        try {
            const result = await authApi.resendCode(email);
            if (result._dev_plain_code) pendingDevCode.value = result._dev_plain_code;
            return result;
        } finally {
            loading.value = false;
        }
    }
```

- [ ] **Step 5: Экспортировать новое** — в `return { ... }` добавить `pendingEmail, pendingDevCode, confirmEmail, resendCode` (рядом с `register`).

- [ ] **Step 6: Проверка типов** — `npm --prefix app run type-check` → 0 ошибок (кроме `RegisterView`, который ещё на старом submit — починим в Task 4).

---

## Task 3: router — роут /confirm-email

**Files:** Modify `app/resources/js/router/index.ts`

- [ ] **Step 1: Добавить роут после блока `/register`** — вставить объект сразу за закрывающей `},` роута register (devIndex 2):

```ts
    {
        path: '/confirm-email',
        name: 'confirm-email',
        component: () => import('../views/auth/ConfirmEmailView.vue'),
        meta: { layout: 'auth', title: 'Подтверждение почты', guestOnly: true, devIndex: 6, devLabel: 'Confirm email' },
    },
```

(devIndex 6 свободен; `guestOnly` — пользователь ещё не залогинен до confirm. Файл `ConfirmEmailView.vue` создаётся в Task 5 — до этого dev-сборка роут лениво не грузит, ошибки нет, но Playwright-проход начинаем после Task 5.)

---

## Task 4: RegisterView.vue — капча-шов + переход на подтверждение

**Files:** Modify `app/resources/js/views/auth/RegisterView.vue`

- [ ] **Step 1: Добавить состояние капчи** — после `const acceptPdn = ref(false);` добавить:

```ts
const captchaAccepted = ref(false);
```

- [ ] **Step 2: Обновить `canSubmit`** — заменить:

```ts
const canSubmit = computed(
    () =>
        email.value.length > 0 &&
        password.value.length >= 8 &&
        acceptOffer.value &&
        acceptPdn.value &&
        captchaAccepted.value,
);
```

- [ ] **Step 3: Переписать `handleSubmit`** — заменить функцию:

```ts
async function handleSubmit() {
    errors.value = {};
    try {
        await auth.register({
            email: email.value,
            password: password.value,
            accept_offer: acceptOffer.value,
            accept_pdn: acceptPdn.value,
            // Шов капчи: на dev backend (NullCaptchaVerifier) принимает любой непустой токен.
            // Реальная Yandex SmartCaptcha заменит заглушку позже (выдаёт настоящий токен).
            captcha_token: captchaAccepted.value ? 'dev-captcha-stub' : '',
        });
        await router.push('/confirm-email');
    } catch (error: unknown) {
        const validationErrors = extractValidationErrors(error);
        if (validationErrors) {
            errors.value = validationErrors;
        } else {
            errors.value = { email: ['Произошла ошибка. Попробуйте позже.'] };
        }
    }
}
```

- [ ] **Step 4: Добавить капча-чекбокс в шаблон** — в `<div class="checks">` после чекбокса `acceptPdn` (перед закрывающим `</div>` блока checks) добавить:

```html
                <v-checkbox
                    v-model="captchaAccepted"
                    density="compact"
                    hide-details
                    color="primary"
                    :error-messages="errors.captcha_token"
                >
                    <template #label>
                        <span class="text-body-2">Подтвердите, что вы не робот</span>
                    </template>
                </v-checkbox>
```

- [ ] **Step 5: Линт** — `npm --prefix app run lint:vue` → 0 ошибок по `RegisterView.vue`.

---

## Task 5: ConfirmEmailView.vue — новый экран подтверждения

**Files:** Create `app/resources/js/views/auth/ConfirmEmailView.vue`

- [ ] **Step 1: Создать компонент**

```vue
<script setup lang="ts">
/**
 * Экран подтверждения почты (G1/SP3a).
 *
 * Пользователь приходит сюда после register: вводит 6-значный код из письма,
 * confirm-email логинит и ведёт на /dashboard. Email переносится через auth-store
 * (pendingEmail); при перезагрузке страницы store пуст → показываем поле email.
 */
import { extractValidationErrors } from '../../api/client';
import { useAuthStore } from '../../stores/auth';
import { computed, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';

const auth = useAuthStore();
const router = useRouter();

// Email: из store (после register) или ручной ввод (после перезагрузки).
const email = ref(auth.pendingEmail ?? '');
const code = ref('');
const errors = ref<Record<string, string[]>>({});
const formError = ref('');
const attemptsRemaining = ref<number | null>(null);

// Кулдаун повторной отправки (30 сек).
const resendCooldown = ref(0);
let cooldownTimer: ReturnType<typeof setInterval> | null = null;

function startCooldown() {
    resendCooldown.value = 30;
    cooldownTimer = setInterval(() => {
        resendCooldown.value -= 1;
        if (resendCooldown.value <= 0 && cooldownTimer) {
            clearInterval(cooldownTimer);
            cooldownTimer = null;
        }
    }, 1000);
}

onUnmounted(() => {
    if (cooldownTimer) clearInterval(cooldownTimer);
});

const devCode = computed(() => auth.pendingDevCode);
const canSubmit = computed(() => /^\d{6}$/.test(code.value) && email.value.length > 0);

async function handleConfirm() {
    errors.value = {};
    formError.value = '';
    attemptsRemaining.value = null;
    try {
        await auth.confirmEmail({ email: email.value, code: code.value });
        await router.push('/dashboard');
    } catch (error: unknown) {
        const validationErrors = extractValidationErrors(error);
        if (validationErrors) {
            errors.value = validationErrors;
        }
        const resp = (error as { response?: { data?: { message?: string; attempts_remaining?: number } } }).response;
        formError.value = resp?.data?.message ?? 'Код подтверждения недействителен.';
        if (typeof resp?.data?.attempts_remaining === 'number') {
            attemptsRemaining.value = resp.data.attempts_remaining;
        }
    }
}

async function handleResend() {
    if (resendCooldown.value > 0 || email.value.length === 0) return;
    formError.value = '';
    try {
        await auth.resendCode(email.value);
        startCooldown();
    } catch {
        formError.value = 'Не удалось отправить код. Попробуйте позже.';
    }
}
</script>

<template>
    <v-card variant="flat" :max-width="380" width="100%" color="transparent" class="confirm-card">
        <header class="confirm-header">
            <h1 class="text-h5 mb-1">Подтвердите почту</h1>
            <p class="text-body-2 text-medium-emphasis ma-0">
                Мы отправили 6-значный код на
                <strong v-if="email">{{ email }}</strong>
                <span v-else>указанный email</span>.
            </p>
        </header>

        <v-form class="confirm-form" @submit.prevent="handleConfirm">
            <v-text-field
                v-if="!auth.pendingEmail"
                v-model="email"
                label="Email"
                type="email"
                autocomplete="email"
                variant="outlined"
                density="comfortable"
                :error-messages="errors.email"
            />

            <v-text-field
                v-model="code"
                label="Код из письма"
                inputmode="numeric"
                maxlength="6"
                placeholder="000000"
                variant="outlined"
                density="comfortable"
                :error-messages="errors.code"
            />

            <p v-if="formError" class="text-body-2 text-error ma-0">
                {{ formError }}
                <span v-if="attemptsRemaining !== null"> Осталось попыток: {{ attemptsRemaining }}.</span>
            </p>

            <p v-if="devCode" class="text-caption text-medium-emphasis ma-0">Код (dev): {{ devCode }}</p>

            <v-btn
                type="submit"
                color="primary"
                block
                size="large"
                variant="flat"
                :disabled="!canSubmit"
                :loading="auth.loading"
            >
                Подтвердить
            </v-btn>

            <v-btn
                variant="text"
                size="small"
                color="primary"
                :disabled="resendCooldown > 0"
                @click="handleResend"
            >
                <template v-if="resendCooldown > 0">Отправить повторно ({{ resendCooldown }})</template>
                <template v-else>Отправить код повторно</template>
            </v-btn>
        </v-form>
    </v-card>
</template>

<style scoped>
.confirm-card {
    display: flex;
    flex-direction: column;
    gap: 16px;
}

.confirm-header h1 {
    font-variation-settings: 'opsz' 26;
    letter-spacing: -0.018em;
}

.confirm-form {
    display: flex;
    flex-direction: column;
    gap: 8px;
}
</style>
```

- [ ] **Step 2: Линт + типы** — `npm --prefix app run lint:vue` и `npm --prefix app run type-check` → 0 ошибок по новому файлу.

---

## Task 6: Playwright — живой прогон (верификация вместо vitest)

**Предусловие:** backend поднят (`php app/artisan serve`, dev-БД с миграциями SP1), фронт собран/dev-сервер запущен. DaData/почта не нужны (dev-код в ответе).

- [ ] **Step 1: Регистрация** — Playwright: открыть `/register`, заполнить новый email (например `sp3a-<rand>@example.org`), пароль (≥8, со спецсимволом — по password-rules), включить оба согласия + капча-чекбокс, нажать «Создать аккаунт».
  Ожидаемо: переход на `/confirm-email`, на экране email и строка «Код (dev): NNNNNN».

- [ ] **Step 2: Подтверждение** — ввести код из dev-подсказки, нажать «Подтвердить».
  Ожидаемо: редирект на `/dashboard`, виден дашборд (пользователь залогинен).

- [ ] **Step 3: Негатив — неверный код** — повторить Step 1 с новым email; на `/confirm-email` ввести `000000` (неверный) → «Подтвердить».
  Ожидаемо: строка ошибки «Код подтверждения недействителен.» + «Осталось попыток: N».

- [ ] **Step 4: Повторная отправка** — на том же экране нажать «Отправить код повторно».
  Ожидаемо: кнопка уходит в кулдаун «Отправить повторно (30…)»; `pendingDevCode` обновился (новая dev-подсказка), новым кодом подтверждение проходит → `/dashboard`.

- [ ] **Step 5: Зафиксировать результат** — кратко отметить прохождение шагов 1-4 (скрин/лог). При расхождении — навык systematic-debugging.

---

## Task 7: Финал — lint + type-check + build + commit (терминал владельца)

- [ ] **Step 1: Линт фронта** — `npm --prefix app run lint:vue` → 0 ошибок.
- [ ] **Step 2: Типы** — `npm --prefix app run type-check` → 0 ошибок.
- [ ] **Step 3: Сборка** — `npm --prefix app run build` → успех (нет ошибок Vite/rollup).
- [ ] **Step 4 (владелец, терминал): коммит**

```
$env:LEFTHOOK="0"
git add app/resources/js/api/auth.ts app/resources/js/stores/auth.ts app/resources/js/router/index.ts app/resources/js/views/auth/RegisterView.vue app/resources/js/views/auth/ConfirmEmailView.vue docs/superpowers/specs/2026-06-18-g1-sp3a-registration-confirm-frontend-spec-v1.md docs/superpowers/plans/2026-06-18-g1-sp3a-registration-confirm-frontend-plan.md
git commit -m "feat: G1/SP3a фронт входа — регистрация + подтверждение почты" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
$env:LEFTHOOK=$null
```

---

## Self-review (выполнено при написании плана)

- **Покрытие спеки:** api-контракты → Task 1; store (actions + pending*) → Task 2; роут → Task 3; RegisterView (капча + переход) → Task 4; ConfirmEmailView (код, resend, кулдаун, dev-подсказка, attempts_remaining, fallback email) → Task 5; проверка Playwright → Task 6; границы (после confirm → /dashboard) соблюдены.
- **Плейсхолдеры:** нет «TBD/TODO»; весь код приведён.
- **Консистентность типов:** `RegisterPayload`(+captcha_token) / `RegisterResult` / `confirmEmail({email,code})` / `resendCode(email)` / `pendingEmail` / `pendingDevCode` — имена совпадают в Task 1/2/4/5.
- **Отступление от TDD задокументировано** (G8 → Playwright) — по указанию владельца.
