Skip to content

Image Polling + Draft Redirect Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: После upload фото — поллить статус обработки. После создания черновика на /new — redirect на /edit/{id} с передачей состояния через Pinia (без мерцания).

Architecture: Новый Pinia store draftTransfer для передачи form state при redirect. Polling в useImageUpload через setInterval с GET /vendor/products/{id}. Redirect логика в ProductFormPage.vue.

Tech Stack: Vue 3.5, Pinia, Nuxt 4, Vitest + @nuxt/test-utils


Task 1: Исправить imageStatusSchema (error → failed)

Бэкенд возвращает 'failed', а не 'error'. Нужно привести в соответствие.

Files:

  • Modify: app/entities/product/model/product.schema.ts:16

Step 1: Проверить текущую schema

В product.schema.ts:16:

ts
export const imageStatusSchema = z.enum(['processing', 'ready', 'error'])

Бэкенд отдаёт 'failed' (из ProductImage.php). Нужно заменить 'error' на 'failed'.

Step 2: Обновить schema

В product.schema.ts:16 заменить:

ts
export const imageStatusSchema = z.enum(['processing', 'ready', 'failed'])

Step 3: Обновить ImageUploader.vue

В ImageUploader.vue:64 проверяется image.status === 'processing' — это ок, не меняем.

Step 4: Запустить typecheck

Run: npm run typecheck Expected: PASS (тип ImageStatus обновится автоматически через z.infer)

Step 5: Коммит

bash
git add app/entities/product/model/product.schema.ts
git commit -m "fix(product): imageStatusSchema 'error' → 'failed' to match backend"

Task 2: Создать useDraftTransferStore

Pinia store для передачи состояния формы при redirect с /new на /edit/{id}.

Files:

  • Create: app/stores/draftTransfer.ts
  • Create: app/stores/draftTransfer.test.ts

Step 1: Написать тест

Файл app/stores/draftTransfer.test.ts:

ts
import { describe, it, expect, beforeEach } from 'vitest'

const { useDraftTransferStore } = await import('./draftTransfer')

describe('useDraftTransferStore', () => {
  beforeEach(() => {
    const store = useDraftTransferStore()
    store.$reset()
  })

  it('starts empty', () => {
    const store = useDraftTransferStore()
    expect(store.hasTransfer).toBe(false)
    expect(store.productId).toBeNull()
  })

  it('save() stores form data and productId', () => {
    const store = useDraftTransferStore()
    const formSnapshot = { title: 'Test', price: 100 }
    const images = [{ id: 1, status: 'ready' }]

    store.save(formSnapshot as any, images as any, [], 42)

    expect(store.hasTransfer).toBe(true)
    expect(store.productId).toBe(42)
    expect(store.formSnapshot).toEqual(formSnapshot)
    expect(store.images).toEqual(images)
  })

  it('save() stores pending files', () => {
    const store = useDraftTransferStore()
    const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' })

    store.save({} as any, [], [file], 42)

    expect(store.pendingFiles).toHaveLength(1)
    expect(store.pendingFiles[0]!.name).toBe('photo.jpg')
  })

  it('take() returns data and clears store', () => {
    const store = useDraftTransferStore()
    store.save({ title: 'Test' } as any, [], [], 42)

    const result = store.take()

    expect(result).not.toBeNull()
    expect(result!.productId).toBe(42)
    expect(result!.formSnapshot).toEqual({ title: 'Test' })

    // Store cleared
    expect(store.hasTransfer).toBe(false)
    expect(store.productId).toBeNull()
  })

  it('take() returns null when empty', () => {
    const store = useDraftTransferStore()
    expect(store.take()).toBeNull()
  })
})

Step 2: Запустить тест — убедиться что FAIL

Run: npm run test:run -- app/stores/draftTransfer.test.ts Expected: FAIL — модуль не существует

Step 3: Реализовать store

Файл app/stores/draftTransfer.ts:

ts
import type { ProductImage } from '~/entities/product/model/product.schema'

interface DraftTransferState {
  formSnapshot: Record<string, unknown> | null
  images: ProductImage[]
  pendingFiles: File[]
  productId: number | null
}

export const useDraftTransferStore = defineStore('draftTransfer', {
  state: (): DraftTransferState => ({
    formSnapshot: null,
    images: [],
    pendingFiles: [],
    productId: null,
  }),

  getters: {
    hasTransfer: (state) => state.productId !== null,
  },

  actions: {
    save(
      formSnapshot: Record<string, unknown>,
      images: ProductImage[],
      pendingFiles: File[],
      productId: number,
    ) {
      this.formSnapshot = formSnapshot
      this.images = images
      this.pendingFiles = pendingFiles
      this.productId = productId
    },

    take() {
      if (!this.hasTransfer) return null
      const data = {
        formSnapshot: this.formSnapshot!,
        images: [...this.images],
        pendingFiles: [...this.pendingFiles],
        productId: this.productId!,
      }
      this.$reset()
      return data
    },
  },
})

Step 4: Запустить тест — убедиться что PASS

Run: npm run test:run -- app/stores/draftTransfer.test.ts Expected: PASS

Step 5: Коммит

bash
git add app/stores/draftTransfer.ts app/stores/draftTransfer.test.ts
git commit -m "feat(stores): add useDraftTransferStore for form state transfer on redirect"

Task 3: Добавить polling в useImageUpload

После upload, если status === 'processing', поллить GET /vendor/products/{productId} каждые 2.5 сек.

Files:

  • Modify: app/features/image-upload/composables/useImageUpload.ts

Step 1: Добавить polling логику

В useImageUpload.ts после строки 22 (const uploading = ref(false)) добавить:

ts
const POLL_INTERVAL = 2500
const POLL_MAX_ATTEMPTS = 24

let pollTimer: ReturnType<typeof setInterval> | null = null
let pollAttempts = 0

Step 2: Добавить функцию hasProcessingImages

После setServerImages (строка 26):

ts
function hasProcessingImages(): boolean {
  return images.value.some(img => img.status === 'processing')
}

Step 3: Добавить функцию pollImageStatuses

ts
async function pollImageStatuses() {
  if (!productId.value) return

  try {
    const response = await api.get<ApiItemResponse<{ images: ProductImage[] }>>(
      `/vendor/products/${productId.value}`,
    )
    const serverImages = (response.data as unknown as { images: ProductImage[] }).images ?? []

    // Обновить только изменившиеся
    images.value = images.value.map(img => {
      const updated = serverImages.find(s => s.id === img.id)
      return updated && updated.status !== img.status ? updated : img
    })
  } catch {
    // Игнорируем ошибки polling — тихий retry
  }
}

Step 4: Добавить startPolling/stopPolling

ts
function startPolling() {
  if (pollTimer) return
  pollAttempts = 0

  pollTimer = setInterval(async () => {
    pollAttempts++

    if (!hasProcessingImages() || pollAttempts >= POLL_MAX_ATTEMPTS) {
      stopPolling()
      return
    }

    await pollImageStatuses()

    if (!hasProcessingImages()) {
      stopPolling()
    }
  }, POLL_INTERVAL)
}

function stopPolling() {
  if (pollTimer) {
    clearInterval(pollTimer)
    pollTimer = null
  }
}

Step 5: Вызывать startPolling после успешного upload

В addFiles (строка 82-84), после images.value = [...images.value, serverImage]:

ts
if (serverImage) {
  images.value = [...images.value, serverImage]
  localImages.value = localImages.value.filter(l => l.id !== localId)
  if (serverImage.status === 'processing') {
    startPolling()
  }
}

Step 6: Cleanup на unmount

В useImageUpload, в самом конце перед return:

ts
onUnmounted(() => {
  stopPolling()
})

Добавить импорт onUnmounted в строку 1:

ts
import { ref, readonly, onUnmounted, type Ref } from 'vue'

Step 7: Экспортировать startPolling для гидрации

В return добавить startPolling:

ts
return {
  // ... existing exports
  startPolling,
}

Step 8: Импортировать ProductImage в pollImageStatuses

Тип ProductImage уже импортирован (строка 5). Также нужен тип ProductDetail:

Заменить строку 5:

ts
import type { ProductImage, ProductDetail } from '~/entities/product/model/product.schema'

И в pollImageStatuses использовать ProductDetail:

ts
const response = await api.get<ApiItemResponse<ProductDetail>>(
  `/vendor/products/${productId.value}`,
)
const serverImages = response.data.images ?? []

Step 9: Запустить typecheck

Run: npm run typecheck Expected: PASS

Step 10: Коммит

bash
git add app/features/image-upload/composables/useImageUpload.ts
git commit -m "feat(image-upload): poll image processing status after upload"

Task 4: Добавить redirect + гидрацию в ProductFormPage

После saveDraft() в create mode → сохранить в Pinia → redirect на /edit/{id}. На mount в edit mode → проверить Pinia → гидрация без загрузки.

Files:

  • Modify: app/features/product-form/ui/ProductFormPage.vue

Step 1: Добавить redirect в onSaveDraft

В ProductFormPage.vue, заменить onSaveDraft (строки 190-192):

ts
async function onSaveDraft() {
  const id = await saveDraft()
  if (id && props.mode === 'create') {
    const transferStore = useDraftTransferStore()
    transferStore.save(JSON.parse(JSON.stringify(form)), [...imageUpload.images.value], [], id)
    skipGuard.value = true
    await navigateTo(`/cabinet/products/${id}/edit`, { replace: true })
  }
}

Step 2: Добавить redirect в onAddFiles

Заменить onAddFiles (строки 112-118):

ts
async function onAddFiles(files: FileList) {
  const fileArray = Array.from(files)

  if (props.mode === 'create') {
    const id = await ensureDraftForUpload()
    if (id) {
      const transferStore = useDraftTransferStore()
      transferStore.save(JSON.parse(JSON.stringify(form)), [...imageUpload.images.value], fileArray, id)
      skipGuard.value = true
      await navigateTo(`/cabinet/products/${id}/edit`, { replace: true })
    }
  } else {
    imageUpload.addFiles(fileArray)
  }
}

Step 3: Гидрация из Pinia на mount

В onMounted (строки 203-218), заменить блок if (props.mode === 'edit'):

ts
onMounted(async () => {
  window.addEventListener('beforeunload', onBeforeUnload)

  await loadCategories()

  if (props.mode === 'edit' && props.productId) {
    const transferStore = useDraftTransferStore()
    const transfer = transferStore.take()

    if (transfer && transfer.productId === props.productId) {
      // Гидрация из Pinia — без запроса к серверу
      Object.assign(form, transfer.formSnapshot)
      imageUpload.setServerImages(transfer.images)

      // Восстановить category chain
      const chain = buildCategoryChain(form.category_ids ?? [], [...partCategories.value], 'part')
      if (chain.length) {
        selectedCategoryChain.value = chain.map((c) => c.id)
      }

      takeSnapshot()

      // Загрузить pending files
      if (transfer.pendingFiles.length) {
        imageUpload.addFiles(transfer.pendingFiles)
      }

      // Запустить polling если есть processing images
      if (transfer.images.some(img => img.status === 'processing')) {
        imageUpload.startPolling()
      }
    } else {
      // Обычная загрузка с сервера
      const product = await loadProduct()
      if (product) {
        imageUpload.setServerImages(product.images ?? [])
        const chain = buildCategoryChain(product.categories ?? [], [...partCategories.value], 'part')
        if (chain.length) {
          selectedCategoryChain.value = chain.map((c) => c.id)
        }
        // Запустить polling если есть processing images
        if (product.images?.some(img => img.status === 'processing')) {
          imageUpload.startPolling()
        }
      }
    }
  }
})

Step 4: Запустить lint + typecheck

Run: npm run lint:fix && npm run typecheck Expected: PASS

Step 5: Коммит

bash
git add app/features/product-form/ui/ProductFormPage.vue
git commit -m "feat(product-form): redirect to edit after draft creation with Pinia state transfer"

Task 5: Ручное тестирование + финальная проверка

Step 1: Запустить все тесты

Run: npm run test:run Expected: PASS

Step 2: Запустить typecheck

Run: npm run typecheck Expected: PASS

Step 3: Запустить lint

Run: npm run lint Expected: PASS

Step 4: Ручной тест-план (dev сервер)

  1. Открыть /cabinet/products/new
  2. Нажать "Сохранить черновик" → URL должен измениться на /cabinet/products/{id}/edit
  3. Форма не должна мерцать — данные сохранены
  4. Перезагрузить страницу → данные загружаются с сервера (edit mode)
  5. Добавить фото → фото загружается, спиннер отображается во время processing
  6. Через 2-5 сек спиннер исчезает, фото отображается с URL
  7. На странице /new добавить фото → redirect на edit, фото начинает грузиться
  8. Перезагрузить → фото уже есть (загружено на сервер ранее)

Step 5: Финальный коммит (если были правки)

bash
git add -A
git commit -m "fix: adjustments after manual testing"