# 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(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(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 ── */} ``` **newString**: ```tsx {/* ── Vídeo de Apresentação ── */}
{ setVideoUrl(e.target.value); markDirty(); }} placeholder="https://www.youtube.com/watch?v=... ou https://vimeo.com/..." className={inputCls()} /> {videoUrl && ( )}
{/* ── 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 (