sass-imobiliaria/specs/033-video-apresentacao-imovel/tasks.md
MatheusAlves96 e1a1f71fbd
Some checks failed
CI/CD → Deploy via SSH / Build & Push Docker Images (push) Successful in 1m6s
CI/CD → Deploy via SSH / Deploy via SSH (push) Successful in 4m18s
CI/CD → Deploy via SSH / Validate HTTPS & Endpoints (push) Has been cancelled
chore: seed sample video data, add spec 033 and update instructions
2026-04-22 23:57:50 -03:00

973 lines
38 KiB
Markdown

# Tasks: Vídeo de Apresentação do Imóvel (033)
**Input**: `specs/033-video-apresentacao-imovel/` — plan.md, spec.md, data-model.md, contracts/api.md
**Branch**: `033-video-apresentacao-imovel`
**Gerado em**: 2026-04-22
---
## Formato: `[ID] [P?] [Story?] Descrição — arquivo`
- **[P]**: Paralelizável com outras tasks do mesmo nível (arquivos distintos, sem dependências em tasks incompletas)
- **[US1/2/3/4]**: User Story correspondente da spec.md
- Cada task termina com o caminho exato do arquivo
---
## Phase 1: Setup — Migration Alembic
**Propósito**: Adicionar as colunas `video_url` e `video_position` à tabela `properties`. Deve ser executada antes de qualquer alteração de model ou schema.
- [ ] T001 Criar arquivo de migration Alembic para adicionar colunas de vídeo à tabela `properties``backend/migrations/versions/k3l4m5n6o7p8_add_video_to_properties.py`
**Conteúdo completo do arquivo a criar**:
```python
"""add video to properties
Revision ID: k3l4m5n6o7p8
Revises: j2k3l4m5n6o7
Create Date: 2026-04-22
"""
import sqlalchemy as sa
from alembic import op
revision = 'k3l4m5n6o7p8'
down_revision = 'j2k3l4m5n6o7'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('properties',
sa.Column('video_url', sa.String(512), nullable=True))
op.add_column('properties',
sa.Column('video_position', sa.String(20),
nullable=False, server_default='section'))
def downgrade() -> None:
op.drop_column('properties', 'video_position')
op.drop_column('properties', 'video_url')
```
**Validação**: Executar `flask db upgrade` dentro do container backend e confirmar que as colunas aparecem em `\d properties` no psql.
---
## Phase 2: Foundational — Backend (Model + Schema + Handler)
**Propósito**: Expor os novos campos em toda a cadeia backend (ORM → Pydantic → handler HTTP). Nenhuma User Story pode ser implementada sem esta fase completa.
**⚠️ CRÍTICO**: T002, T003 e T004 podem rodar em paralelo (arquivos distintos). T005 depende de T004.
- [ ] T002 [P] Adicionar colunas `video_url` e `video_position` ao modelo SQLAlchemy `Property` — `backend/app/models/property.py`
**oldString** (após `created_at`, antes de `photos = db.relationship`):
```python
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
photos = db.relationship(
```
**newString**:
```python
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
video_url = db.Column(db.String(512), nullable=True)
video_position = db.Column(db.String(20), nullable=False, server_default='section')
photos = db.relationship(
```
---
- [ ] T003 [P] Adicionar campos `video_url` e `video_position` em `PropertyDetailOut` — `backend/app/schemas/property.py`
**oldString**:
```python
class PropertyDetailOut(PropertyOut):
address: str | None = None
code: str | None = None
description: str | None = None
```
**newString**:
```python
class PropertyDetailOut(PropertyOut):
address: str | None = None
code: str | None = None
description: str | None = None
video_url: str | None = None
video_position: Literal['carousel', 'section'] = 'section'
```
> `Literal` já está importado em `from typing import Literal` (linha 6 do arquivo).
---
- [ ] T004 [P] Adicionar `Literal` ao import, campos em `PropertyAdminOut` e mapeamento em `from_prop` — `backend/app/routes/admin.py`
**Passo 4a** — Adicionar `Literal` ao import de typing:
**oldString**:
```python
from typing import Optional
```
**newString**:
```python
from typing import Optional, Literal
```
**Passo 4b** — Adicionar campos ao final do corpo de `PropertyAdminOut` (antes do `@classmethod`):
**oldString**:
```python
is_active: bool
is_featured: bool
photos: list[PhotoAdminOut] = []
amenity_ids: list[int] = []
@classmethod
def from_prop(cls, p: Property) -> "PropertyAdminOut":
```
**newString**:
```python
is_active: bool
is_featured: bool
photos: list[PhotoAdminOut] = []
amenity_ids: list[int] = []
video_url: Optional[str] = None
video_position: Literal['carousel', 'section'] = 'section'
@classmethod
def from_prop(cls, p: Property) -> "PropertyAdminOut":
```
**Passo 4c** — Adicionar campos ao `return cls(...)` de `from_prop`:
**oldString**:
```python
amenity_ids=[a.id for a in p.amenities],
)
# ─── Imóveis ─────────────────────────────────────────────────────────────────
```
**newString**:
```python
amenity_ids=[a.id for a in p.amenities],
video_url=p.video_url,
video_position=p.video_position,
)
# ─── Imóveis ─────────────────────────────────────────────────────────────────
```
---
- [ ] T005 Adicionar `video_position` em `_SCALAR_FIELDS` e handler de sanitização de `video_url` em `admin_update_property` — `backend/app/routes/admin.py`
**Passo 5a** — Adicionar `video_position` em `_SCALAR_FIELDS`:
**oldString**:
```python
"city_id",
"neighborhood_id",
)
for field in _SCALAR_FIELDS:
if field in body:
setattr(prop, field, body[field])
# code: tratar string vazia como NULL
if "code" in body:
```
**newString**:
```python
"city_id",
"neighborhood_id",
"video_position",
)
for field in _SCALAR_FIELDS:
if field in body:
setattr(prop, field, body[field])
# video_url: sanitizar e tratar string vazia como NULL
if 'video_url' in body:
raw = body['video_url']
prop.video_url = raw.strip() if raw and raw.strip() else None
# code: tratar string vazia como NULL
if "code" in body:
```
**Validação da Phase 2**: Reiniciar o container backend, fazer `PUT /api/admin/properties/:id` com `{"video_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "video_position": "section"}` e confirmar que `GET /api/properties/:slug` retorna os campos `video_url` e `video_position`.
---
## Phase 3: User Story 1 — Administrador Configura Vídeo (P1)
**Goal**: O administrador pode informar URL de vídeo, escolher posição e salvar/remover via painel admin.
**Independent Test**: Autenticar como admin, abrir edição de um imóvel existente, preencher `video_url` com `https://www.youtube.com/watch?v=dQw4w9WgXcQ`, selecionar "Seção exclusiva", salvar. Verificar via `GET /api/properties/:slug` que `video_url` está preenchido e `video_position === "section"`.
- [ ] T006 [P] [US1] Adicionar `video_url` e `video_position` à interface `AdminProperty` — `frontend/src/pages/admin/AdminPropertiesPage.tsx`
**oldString**:
```typescript
description: string | null
is_active: boolean
is_featured: boolean
photos: AdminPhoto[]
amenity_ids: number[]
}
```
**newString**:
```typescript
description: string | null
is_active: boolean
is_featured: boolean
photos: AdminPhoto[]
amenity_ids: number[]
video_url: string | null
video_position: 'carousel' | 'section'
}
```
---
- [ ] T007 [US1] Adicionar `video_url` e `video_position` à interface `PropertyFormData` — `frontend/src/pages/admin/PropertyForm.tsx`
**oldString**:
```typescript
photos: PhotoItem[];
amenity_ids: number[];
}
```
**newString**:
```typescript
photos: PhotoItem[];
amenity_ids: number[];
video_url: string;
video_position: 'carousel' | 'section';
}
```
---
- [ ] T008 [US1] Adicionar estados `videoUrl`/`videoPosition`, campos UI (input URL + select posição + botão remover) e incluir no `handleSubmit` — `frontend/src/pages/admin/PropertyForm.tsx`
**Passo 8a** — Adicionar estados após `description`:
**oldString**:
```typescript
const [description, setDescription] = useState(initial?.description ?? '');
const [photos, setPhotos] = useState<PhotoItem[]>(initial?.photos ?? []);
```
**newString**:
```typescript
const [description, setDescription] = useState(initial?.description ?? '');
const [videoUrl, setVideoUrl] = useState(initial?.video_url ?? '');
const [videoPosition, setVideoPosition] = useState<'carousel' | 'section'>(
initial?.video_position ?? 'section'
);
const [photos, setPhotos] = useState<PhotoItem[]>(initial?.photos ?? []);
```
**Passo 8b** — Incluir campos de vídeo no `onSubmit` dentro de `handleSubmit`:
**oldString**:
```typescript
description: description.trim(), photos, amenity_ids: amenityIds,
});
```
**newString**:
```typescript
description: description.trim(), photos, amenity_ids: amenityIds,
video_url: videoUrl.trim(), video_position: videoPosition,
});
```
**Passo 8c** — Adicionar seção de UI "Vídeo de Apresentação" após a seção "Descrição" e antes de "Fotos":
**oldString**:
```tsx
{/* ── Fotos ── */}
<SectionDivider title="Fotos" />
```
**newString**:
```tsx
{/* ── Vídeo de Apresentação ── */}
<SectionDivider title="Vídeo de Apresentação" />
<div className="space-y-3">
<div>
<Label>URL do vídeo</Label>
<div className="flex gap-2 items-start">
<input
type="url"
value={videoUrl}
onChange={e => { setVideoUrl(e.target.value); markDirty(); }}
placeholder="https://www.youtube.com/watch?v=... ou https://vimeo.com/..."
className={inputCls()}
/>
{videoUrl && (
<button
type="button"
onClick={() => { setVideoUrl(''); markDirty(); }}
className="flex-shrink-0 px-3 py-2 rounded-lg border border-red-500/40 text-red-400 text-sm hover:bg-red-500/10 transition-colors whitespace-nowrap"
>
Remover vídeo
</button>
)}
</div>
</div>
<div>
<Label>Posição do vídeo</Label>
<select
value={videoPosition}
onChange={e => {
setVideoPosition(e.target.value as 'carousel' | 'section');
markDirty();
}}
className={inputCls()}
>
<option value="section">Seção exclusiva (após o carrossel)</option>
<option value="carousel">No carrossel de fotos (primeiro slide)</option>
</select>
</div>
</div>
{/* ── Fotos ── */}
<SectionDivider title="Fotos" />
```
---
- [ ] T009 [US1] Mapear `video_url` e `video_position` no `initial` prop ao abrir edição — `frontend/src/pages/admin/AdminPropertiesPage.tsx`
**oldString**:
```typescript
initial={editing ? {
...editing,
price: String(editing.price),
condo_fee: editing.condo_fee ? String(editing.condo_fee) : '',
iptu_anual: editing.iptu_anual ? String(editing.iptu_anual) : '',
city_id: editing.city_id ?? '',
neighborhood_id: editing.neighborhood_id ?? '',
code: editing.code ?? '',
description: editing.description ?? '',
amenity_ids: editing.amenity_ids ?? [],
} : undefined}
```
**newString**:
```typescript
initial={editing ? {
...editing,
price: String(editing.price),
condo_fee: editing.condo_fee ? String(editing.condo_fee) : '',
iptu_anual: editing.iptu_anual ? String(editing.iptu_anual) : '',
city_id: editing.city_id ?? '',
neighborhood_id: editing.neighborhood_id ?? '',
code: editing.code ?? '',
description: editing.description ?? '',
amenity_ids: editing.amenity_ids ?? [],
video_url: editing.video_url ?? '',
video_position: editing.video_position ?? 'section',
} : undefined}
```
**Checkpoint US1**: Abrir painel admin → editar imóvel → preencher URL → selecionar posição → salvar. Confirmar que `video_url` persiste. Confirmar que "Remover vídeo" limpa o campo. Confirmar que imóvel sem vídeo abre o form sem erro.
---
## Phase 4: User Story 2 — Visitante Assiste ao Vídeo (P1)
**Goal**: A página pública de detalhe do imóvel exibe o player de vídeo na posição configurada.
**Independent Test**: Acessar a URL de detalhe de um imóvel com `video_url` e `video_position = 'section'`. Confirmar que a seção "Vídeo de Apresentação" aparece após o carrossel. Repetir com `video_position = 'carousel'` e confirmar que o vídeo é o slide 0 do carrossel.
- [ ] T010 [P] [US2] Criar utilitário `getEmbedUrl` — `frontend/src/utils/getEmbedUrl.ts` [NOVO]
**Conteúdo completo do arquivo a criar**:
```typescript
export type VideoType = 'youtube' | 'vimeo' | 'direct' | 'unknown'
export interface EmbedResult {
type: VideoType
embedUrl: string | null
}
export function getEmbedUrl(url: string): EmbedResult {
const trimmed = url.trim()
if (!trimmed) return { type: 'unknown', embedUrl: null }
// YouTube — watch?v=, youtu.be/, embed/
const ytWatch = trimmed.match(
/(?:youtube\.com\/watch\?(?:.*&)?v=|youtu\.be\/)([A-Za-z0-9_-]{11})/
)
if (ytWatch) {
return { type: 'youtube', embedUrl: `https://www.youtube.com/embed/${ytWatch[1]}` }
}
if (/youtube\.com\/embed\/[A-Za-z0-9_-]{11}/.test(trimmed)) {
return { type: 'youtube', embedUrl: trimmed }
}
// Vimeo — vimeo.com/ID ou player.vimeo.com/video/ID
const vimeoMatch = trimmed.match(/vimeo\.com\/(?:video\/)?(\d+)/)
if (vimeoMatch) {
return { type: 'vimeo', embedUrl: `https://player.vimeo.com/video/${vimeoMatch[1]}` }
}
if (/player\.vimeo\.com\/video\/\d+/.test(trimmed)) {
return { type: 'vimeo', embedUrl: trimmed }
}
// Arquivo direto de vídeo (.mp4 ou .webm)
if (/\.(mp4|webm)(\?.*)?$/i.test(trimmed)) {
return { type: 'direct', embedUrl: trimmed }
}
return { type: 'unknown', embedUrl: null }
}
```
---
- [ ] T011 [P] [US2] Adicionar `video_url` e `video_position` à interface `PropertyDetail` — `frontend/src/types/property.ts`
**oldString**:
```typescript
export interface PropertyDetail extends Property {
address: string | null
code: string | null
description: string | null
}
```
**newString**:
```typescript
export interface PropertyDetail extends Property {
address: string | null
code: string | null
description: string | null
video_url: string | null
video_position: 'carousel' | 'section'
}
```
---
- [ ] T012 [US2] Criar componente `VideoPlayer` — `frontend/src/components/PropertyDetail/VideoPlayer.tsx` [NOVO]
> Depende de T010 (`getEmbedUrl.ts` já criado).
**Conteúdo completo do arquivo a criar**:
```tsx
import { getEmbedUrl } from '../../utils/getEmbedUrl'
interface VideoPlayerProps {
url: string
className?: string
}
export default function VideoPlayer({ url, className = '' }: VideoPlayerProps) {
const { type, embedUrl } = getEmbedUrl(url)
if (type === 'youtube' || type === 'vimeo') {
return (
<div
className={`relative w-full aspect-[16/9] rounded-xl overflow-hidden bg-panel border border-white/5 ${className}`}
>
<iframe
src={embedUrl!}
title="Vídeo de apresentação do imóvel"
className="absolute inset-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
loading="lazy"
/>
</div>
)
}
if (type === 'direct') {
return (
<video
src={embedUrl!}
controls
className={`w-full rounded-xl bg-panel border border-white/5 ${className}`}
/>
)
}
// type === 'unknown': URL inválida ou domínio não suportado — não exibe nada
return null
}
```
---
- [ ] T013 [US2] Adicionar suporte a `videoUrl` no `PhotoCarousel` (slide 0 = vídeo, thumbnails, counter) — `frontend/src/components/PropertyDetail/PhotoCarousel.tsx`
> Depende de T012 (`VideoPlayer.tsx` já criado).
**Passo 13a** — Adicionar import do `VideoPlayer` e atualizar interface:
**oldString**:
```typescript
import { useCallback, useEffect, useRef, useState } from 'react'
import type { PropertyPhoto } from '../../types/property'
interface PhotoCarouselProps {
photos: PropertyPhoto[]
}
```
**newString**:
```typescript
import { useCallback, useEffect, useRef, useState } from 'react'
import type { PropertyPhoto } from '../../types/property'
import VideoPlayer from './VideoPlayer'
interface PhotoCarouselProps {
photos: PropertyPhoto[]
videoUrl?: string | null
}
```
**Passo 13b** — Atualizar assinatura do componente, adicionar `totalSlides` e corrigir `prev`/`next`:
**oldString**:
```typescript
export default function PhotoCarousel({ photos }: PhotoCarouselProps) {
const [activeIndex, setActiveIndex] = useState(0)
const touchStartX = useRef<number | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const prev = useCallback(() => {
setActiveIndex((i) => (i === 0 ? photos.length - 1 : i - 1))
}, [photos.length])
const next = useCallback(() => {
setActiveIndex((i) => (i === photos.length - 1 ? 0 : i + 1))
}, [photos.length])
```
**newString**:
```typescript
export default function PhotoCarousel({ photos, videoUrl }: PhotoCarouselProps) {
const [activeIndex, setActiveIndex] = useState(0)
const touchStartX = useRef<number | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const hasVideo = Boolean(videoUrl)
const totalSlides = photos.length + (hasVideo ? 1 : 0)
const prev = useCallback(() => {
setActiveIndex((i) => (i === 0 ? totalSlides - 1 : i - 1))
}, [totalSlides])
const next = useCallback(() => {
setActiveIndex((i) => (i === totalSlides - 1 ? 0 : i + 1))
}, [totalSlides])
```
**Passo 13c** — Atualizar early return e variáveis derivadas:
**oldString**:
```typescript
if (photos.length === 0) return <NoPhotoPlaceholder />
const active = photos[activeIndex]
const single = photos.length === 1
```
**newString**:
```typescript
if (totalSlides === 0) return <NoPhotoPlaceholder />
const isVideoSlide = hasVideo && activeIndex === 0
const photoIndex = hasVideo ? activeIndex - 1 : activeIndex
const active = !isVideoSlide ? photos[photoIndex] : null
const single = totalSlides === 1
```
**Passo 13d** — Substituir bloco do slide principal para suportar vídeo:
**oldString**:
```tsx
{/* Main photo */}
<div className="relative w-full aspect-[16/9] rounded-xl overflow-hidden bg-panel">
<img
src={active.url}
alt={active.alt_text || `Foto ${activeIndex + 1}`}
className="w-full h-full object-cover transition-opacity duration-300"
loading="lazy"
/>
```
**newString**:
```tsx
{/* Main slide */}
<div className="relative w-full rounded-xl overflow-hidden bg-panel">
{isVideoSlide ? (
<VideoPlayer url={videoUrl!} />
) : (
<div className="aspect-[16/9]">
<img
src={active!.url}
alt={active!.alt_text || `Foto ${activeIndex + 1}`}
className="w-full h-full object-cover transition-opacity duration-300"
loading="lazy"
/>
</div>
)}
```
**Passo 13e** — Atualizar o counter para usar `totalSlides`:
**oldString**:
```tsx
{/* Counter */}
<div className="absolute bottom-3 right-3 bg-black/60 backdrop-blur-sm rounded-full px-2.5 py-1 text-xs text-white/80">
{activeIndex + 1} / {photos.length}
</div>
```
**newString**:
```tsx
{/* Counter */}
<div className="absolute bottom-3 right-3 bg-black/60 backdrop-blur-sm rounded-full px-2.5 py-1 text-xs text-white/80">
{activeIndex + 1} / {totalSlides}
</div>
```
**Passo 13f** — Adicionar thumbnail de vídeo ao início do strip e corrigir índices dos thumbnails de foto:
**oldString**:
```tsx
{photos.map((photo, idx) => (
<button
key={idx}
onClick={() => setActiveIndex(idx)}
aria-label={`Ver foto ${idx + 1}`}
aria-current={idx === activeIndex}
className={`flex-shrink-0 w-20 h-14 rounded-lg overflow-hidden border-2 transition-all duration-150 ${idx === activeIndex
? 'border-accent-violet opacity-100'
: 'border-transparent opacity-50 hover:opacity-75'
}`}
>
<img
src={photo.url}
alt={photo.alt_text || `Miniatura ${idx + 1}`}
className="w-full h-full object-cover"
loading="lazy"
/>
</button>
))}
```
**newString**:
```tsx
{hasVideo && (
<button
key="video-thumb"
onClick={() => setActiveIndex(0)}
aria-label="Ver vídeo"
aria-current={activeIndex === 0}
className={`flex-shrink-0 w-20 h-14 rounded-lg overflow-hidden border-2 transition-all duration-150 flex items-center justify-center bg-panel ${
activeIndex === 0
? 'border-accent-violet opacity-100'
: 'border-transparent opacity-50 hover:opacity-75'
}`}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="currentColor"
className="text-white/70"
aria-hidden="true"
>
<path d="M8 5v14l11-7z" />
</svg>
</button>
)}
{photos.map((photo, idx) => {
const slideIdx = hasVideo ? idx + 1 : idx
return (
<button
key={idx}
onClick={() => setActiveIndex(slideIdx)}
aria-label={`Ver foto ${idx + 1}`}
aria-current={slideIdx === activeIndex}
className={`flex-shrink-0 w-20 h-14 rounded-lg overflow-hidden border-2 transition-all duration-150 ${
slideIdx === activeIndex
? 'border-accent-violet opacity-100'
: 'border-transparent opacity-50 hover:opacity-75'
}`}
>
<img
src={photo.url}
alt={photo.alt_text || `Miniatura ${idx + 1}`}
className="w-full h-full object-cover"
loading="lazy"
/>
</button>
)
})}
```
---
- [ ] T014 [US2] Adicionar import do `VideoPlayer` e lógica condicional de vídeo (carousel vs section) — `frontend/src/pages/PropertyDetailPage.tsx`
> Depende de T011 (tipo atualizado) e T012 (VideoPlayer criado).
**Passo 14a** — Adicionar import do `VideoPlayer`:
**oldString**:
```typescript
import PhotoCarousel from '../components/PropertyDetail/PhotoCarousel';
```
**newString**:
```typescript
import PhotoCarousel from '../components/PropertyDetail/PhotoCarousel';
import VideoPlayer from '../components/PropertyDetail/VideoPlayer';
```
**Passo 14b** — Passar `videoUrl` ao `PhotoCarousel` e renderizar seção exclusiva condicionalmente:
**oldString**:
```tsx
{/* Carousel */}
<PhotoCarousel photos={property.photos} />
{/* Stats */}
```
**newString**:
```tsx
{/* Carousel */}
<PhotoCarousel
photos={property.photos}
videoUrl={
property.video_position === 'carousel'
? (property.video_url ?? null)
: null
}
/>
{/* Vídeo de Apresentação — seção exclusiva */}
{property.video_url && property.video_position === 'section' && (
<div>
<h2 className="text-sm font-medium text-textQuaternary uppercase tracking-[0.08em] mb-3">
Vídeo de Apresentação
</h2>
<VideoPlayer url={property.video_url} />
</div>
)}
{/* Stats */}
```
**Checkpoint US2**: Acessar imóvel com `video_position = 'section'` — seção aparece após carrossel. Acessar com `video_position = 'carousel'` — vídeo é slide 0. Acessar imóvel sem vídeo — página idêntica ao estado anterior.
---
## Phase 5: User Story 3 — Administrador Pré-visualiza o Vídeo (P2)
**Goal**: O admin vê um preview do player enquanto digita a URL, sem precisar salvar.
**Independent Test**: Na tela de edição, digitar `https://www.youtube.com/watch?v=dQw4w9WgXcQ`. Um player iframe deve aparecer abaixo do campo em tempo real. Trocar por URL inválida — preview deve sumir sem erro. Trocar por URL `.mp4` — player nativo deve aparecer.
- [ ] T015 [US3] Adicionar import de `getEmbedUrl` e bloco de preview em tempo real na seção de vídeo do formulário — `frontend/src/pages/admin/PropertyForm.tsx`
> Depende de T010 (`getEmbedUrl.ts`) e T008 (UI de vídeo já presente).
**Passo 15a** — Adicionar import:
**oldString**:
```typescript
import api from '../../services/api';
import { useEffect, useRef, useState, useCallback } from 'react';
```
**newString**:
```typescript
import api from '../../services/api';
import { useEffect, useRef, useState, useCallback } from 'react';
import { getEmbedUrl } from '../../utils/getEmbedUrl';
```
**Passo 15b** — Adicionar preview entre o bloco do input URL/botão remover e o select de posição:
**oldString**:
```tsx
<div>
<Label>Posição do vídeo</Label>
<select
value={videoPosition}
onChange={e => {
setVideoPosition(e.target.value as 'carousel' | 'section');
markDirty();
}}
className={inputCls()}
>
<option value="section">Seção exclusiva (após o carrossel)</option>
<option value="carousel">No carrossel de fotos (primeiro slide)</option>
</select>
</div>
```
**newString**:
```tsx
{videoUrl.trim() && (() => {
const { type, embedUrl } = getEmbedUrl(videoUrl)
if ((type === 'youtube' || type === 'vimeo') && embedUrl) {
return (
<div className="relative w-full aspect-[16/9] rounded-xl overflow-hidden bg-panel border border-white/5">
<iframe
src={embedUrl}
title="Preview do vídeo"
className="absolute inset-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
loading="lazy"
/>
</div>
)
}
if (type === 'direct' && embedUrl) {
return (
<video
src={embedUrl}
controls
className="w-full rounded-xl bg-panel border border-white/5"
/>
)
}
return null
})()}
<div>
<Label>Posição do vídeo</Label>
<select
value={videoPosition}
onChange={e => {
setVideoPosition(e.target.value as 'carousel' | 'section');
markDirty();
}}
className={inputCls()}
>
<option value="section">Seção exclusiva (após o carrossel)</option>
<option value="carousel">No carrossel de fotos (primeiro slide)</option>
</select>
</div>
```
**Checkpoint US3**: Digitar URL válida do YouTube — iframe aparece em ≤ 3s. Digitar URL inválida — nenhum preview, nenhum erro. Digitar URL `.mp4` — `<video controls>` aparece.
---
## Phase 6: US4 + Polish — Validação de Não-Regressão
**Goal**: Garantir que imóveis sem vídeo mantêm comportamento idêntico ao pré-feature, e que o build TypeScript está limpo.
**Independent Test (US4)**: Acessar um imóvel existente sem `video_url`. A página de detalhe não deve ter seção de vídeo, carrossel sem slide extra, sem erros no console.
- [ ] T016 Executar build do frontend para validar tipos TypeScript sem erros — `frontend/`
```bash
cd frontend && npm run build
```
**Critério de sucesso**: Build finaliza com `✓ built in X.Xs` sem erros de tipo. Warnings são aceitos; erros de tipo (`error TS`) são bloqueadores.
---
## Grafo de Dependências
```
T001 (migration)
└─► T002, T003, T004 [paralelos]
└─► T005
└─► T006, T007 [paralelos]
└─► T008
└─► T009
└─► T015 (preview, depende de T010)
T010 (getEmbedUrl) ─── paralelo com T006/T007
└─► T012 (VideoPlayer)
└─► T013 (PhotoCarousel)
└─► T014 (PropertyDetailPage)
└─► T016 (build)
T011 (types/property.ts) ─── paralelo com T010
└─► T014
```
### Execução Paralela por User Story
| Story | Tasks Paralelas | Bloqueadores |
|-------|----------------|--------------|
| Backend (fundação) | T002, T003, T004 | T001 |
| US1 (admin) | T006, T007 | T005 |
| US2 (visitante) | T010, T011 | T005 |
| US3 (preview) | — | T008, T010 |
---
## MVP Sugerido
Entregar apenas US1 + US2 (ambas P1):
1. T001 → T002 → T003 → T004 → T005 (backend)
2. Em paralelo: T006 + T007 → T008 → T009 (US1 admin)
3. Em paralelo: T010 + T011 → T012 → T013 → T014 (US2 visitante)
4. T016 (build)
US3 (preview no admin) e US4 (garantia de não-regressão) são incrementais sobre o MVP.
---
## Checklist de Validação Final
### Backend
- [ ] `flask db upgrade` executa sem erros; `flask db downgrade` reverte sem erros
- [ ] `GET /api/properties/:slug` retorna `video_url` (string ou null) e `video_position` em todos os imóveis
- [ ] `PUT /api/admin/properties/:id` com `{"video_url": " https://youtu.be/abc ", "video_position": "carousel"}` persiste `video_url` com strip aplicado
- [ ] `PUT /api/admin/properties/:id` com `{"video_url": ""}` salva `video_url = null`
- [ ] `PUT /api/admin/properties/:id` sem `video_url` no body não altera o campo existente
- [ ] Endpoint rejeita request sem JWT admin com 401
### Frontend — Admin
- [ ] Formulário de edição exibe campo URL, select de posição e botão "Remover vídeo"
- [ ] Ao abrir edição de imóvel com vídeo, campos vêm pré-preenchidos
- [ ] Salvar com URL vazia limpa o vídeo no backend
- [ ] Preview YouTube aparece ao digitar URL válida; desaparece ao limpar o campo
- [ ] Preview não exibe nada para URLs de domínio não suportado (sem exceção JS)
- [ ] Build TypeScript (`npm run build`) sem erros de tipo
### Frontend — Visitante
- [ ] Imóvel com `video_position = 'section'`: seção "Vídeo de Apresentação" aparece após carrossel
- [ ] Imóvel com `video_position = 'carousel'`: vídeo é o slide 0; fotos começam no slide 1; thumbnail de play aparece no strip
- [ ] Imóvel sem vídeo: página idêntica ao estado anterior, sem seção vazia, sem slide extra
- [ ] URL YouTube válida → iframe embed; URL Vimeo válida → iframe embed; URL `.mp4` → `<video controls>`; URL inválida → nada renderizado (sem crash)
- [ ] Player responsivo em mobile (sem overflow horizontal)
- [ ] Counter do carrossel exibe corretamente (ex.: `1 / 5` para o slide de vídeo quando há 4 fotos)
### getEmbedUrl
- [ ] `https://www.youtube.com/watch?v=dQw4w9WgXcQ` → `{ type: 'youtube', embedUrl: 'https://www.youtube.com/embed/dQw4w9WgXcQ' }`
- [ ] `https://youtu.be/dQw4w9WgXcQ` → `{ type: 'youtube', embedUrl: 'https://www.youtube.com/embed/dQw4w9WgXcQ' }`
- [ ] `https://vimeo.com/123456789` → `{ type: 'vimeo', embedUrl: 'https://player.vimeo.com/video/123456789' }`
- [ ] `https://cdn.example.com/video.mp4` → `{ type: 'direct', embedUrl: 'https://cdn.example.com/video.mp4' }`
- [ ] `https://exemplo.com/pagina` → `{ type: 'unknown', embedUrl: null }`
- [ ] `""` (string vazia) → `{ type: 'unknown', embedUrl: null }`