13 KiB
Implementation Plan: Vídeo de Apresentação do Imóvel
Branch: 033-video-apresentacao-imovel | Data: 2026-04-22 | Spec: spec.md
Input: specs/033-video-apresentacao-imovel/spec.md
Summary
Adicionar suporte a um vídeo de apresentação por imóvel: o administrador configura a URL (YouTube, Vimeo ou arquivo direto) e escolhe se o vídeo aparece integrado ao carrossel de fotos ou em seção exclusiva abaixo dele. A implementação consiste em uma migration Alembic (+2 colunas), extensões mínimas nos schemas Pydantic e no handler de update existente no backend, e três artefatos novos no frontend (getEmbedUrl, VideoPlayer, modificação do PhotoCarousel).
Technical Context
Language/Version: Python 3.12 (backend) · TypeScript 5.5 (frontend)
Primary Dependencies: Flask 3.x, SQLAlchemy 2.x, Pydantic v2, Alembic (backend) · React 18, Tailwind CSS 3.4 (frontend) — sem novas dependências
Storage: PostgreSQL 16 — tabela properties recebe video_url VARCHAR(512) NULL e video_position VARCHAR(20) NOT NULL DEFAULT 'section'
Testing: pytest (backend) · testes manuais via browser (frontend)
Target Platform: Web SPA (React) + REST API (Flask)
Project Type: Web application — SaaS imobiliária
Performance Goals: Preview do vídeo no admin ≤ 3s (SC-006); página de detalhe não deve sofrer regressão de performance (vídeo só carrega quando configurado)
Constraints: Sem novas dependências npm; sem ENUM SQL (usar VARCHAR + validação Pydantic); iframe com loading="lazy" para não bloquear LCP
Scale/Scope: Feature aditiva — 1 migration, 1 utilitário, 1 componente, modificações pontuais em 6 arquivos existentes
Constitution Check
| Princípio | Status | Notas |
|---|---|---|
| I. Design-First | ✅ PASSA | VideoPlayer usa aspect-[16/9], rounded-xl, cores bg-panel border border-white/5 do design system; nenhum estilo inline fora do sistema |
| II. Separation of Concerns | ✅ PASSA | Backend persiste URL bruta; parse/transform de embed é 100% client-side; contrato REST documentado em contracts/api.md |
| III. Spec-Driven | ✅ PASSA | spec.md aprovado → plan.md → tasks.md → implementação; ordem respeitada |
| IV. Data Integrity | ✅ PASSA | Migration Alembic com upgrade/downgrade; campos declarados com nullable explícito; Pydantic Literal para video_position; sanitização de URL antes de persistir |
| V. Security | ✅ PASSA | Endpoint admin protegido por @require_admin; sem secrets no frontend; video_url é dado público sem impacto de segurança; iframe com allow explícito |
| VI. Simplicity First | ✅ PASSA | Sem novas dependências; helper puro sem estado; modificação do carrossel existente em vez de criar novo; YAGNI respeitado (1 vídeo por imóvel) |
Project Structure
Documentation (esta feature)
specs/033-video-apresentacao-imovel/
├── plan.md ← este arquivo
├── research.md ← decisões técnicas documentadas
├── data-model.md ← modelo de dados e migration
├── quickstart.md ← guia de setup e teste
├── contracts/
│ └── api.md ← contratos REST + utilitários frontend
└── tasks.md ← gerado por /speckit.tasks (próximo passo)
Source Code — Arquivos Afetados
backend/
├── migrations/versions/
│ └── k3l4m5n6o7p8_add_video_to_properties.py [NOVO]
├── app/
│ ├── models/
│ │ └── property.py [MODIFICADO]
│ ├── schemas/
│ │ └── property.py [MODIFICADO]
│ └── routes/
│ └── admin.py [MODIFICADO]
frontend/src/
├── utils/
│ └── getEmbedUrl.ts [NOVO]
├── components/
│ └── PropertyDetail/
│ ├── VideoPlayer.tsx [NOVO]
│ └── PhotoCarousel.tsx [MODIFICADO]
├── pages/
│ ├── PropertyDetailPage.tsx [MODIFICADO]
│ └── admin/
│ └── PropertyForm.tsx [MODIFICADO]
└── types/
└── property.ts [MODIFICADO]
Implementation Phases
Fase 1 — Backend (sem frontend)
1.1 Migration Alembic
Arquivo: backend/migrations/versions/k3l4m5n6o7p8_add_video_to_properties.py
"""add video to properties
Revision ID: k3l4m5n6o7p8
Revises: j2k3l4m5n6o7
Create Date: 2026-04-22
"""
from alembic import op
import sqlalchemy as sa
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')
1.2 Model — backend/app/models/property.py
Adicionar após created_at:
video_url = db.Column(db.String(512), nullable=True)
video_position = db.Column(db.String(20), nullable=False, server_default='section')
1.3 Schema público — backend/app/schemas/property.py
Adicionar campos em PropertyDetailOut:
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'
1.4 Schema admin + handler — backend/app/routes/admin.py
PropertyAdminOut — adicionar campos:
video_url: str | None = None
video_position: Literal['carousel', 'section'] = 'section'
PropertyAdminOut.from_prop — adicionar no return cls(...):
video_url=p.video_url,
video_position=p.video_position,
admin_update_property — adicionar 'video_position' em _SCALAR_FIELDS e, fora do loop, o tratamento de video_url:
# Após o loop de _SCALAR_FIELDS:
if 'video_url' in body:
raw = body['video_url']
prop.video_url = raw.strip() if raw and raw.strip() else None
Nota:
video_positionentra em_SCALAR_FIELDSnormalmente.video_urlprecisa de tratamento fora do loop para sanitização (strip + string vazia → None).
Fase 2 — Frontend Utilitário + Componente Base
2.1 frontend/src/utils/getEmbedUrl.ts — NOVO
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, 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
const vimeoMatch = trimmed.match(/vimeo\.com\/(\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
if (/\.(mp4|webm)(\?.*)?$/i.test(trimmed)) {
return { type: 'direct', embedUrl: trimmed }
}
return { type: 'unknown', embedUrl: null }
}
2.2 frontend/src/components/PropertyDetail/VideoPlayer.tsx — NOVO
import { getEmbedUrl } from '../../utils/getEmbedUrl'
interface VideoPlayerProps {
url: string
className?: string
}
export default function VideoPlayer({ url, className = '' }: VideoPlayerProps) {
const { type, embedUrl } = getEmbedUrl(url)
if (!embedUrl) return null
const wrapperClass =
`relative w-full aspect-[16/9] rounded-xl overflow-hidden bg-panel border border-white/5 ${className}`
if (type === 'direct') {
return (
<div className={wrapperClass}>
<video
src={embedUrl}
controls
className="absolute inset-0 w-full h-full"
title="Vídeo de apresentação do imóvel"
/>
</div>
)
}
return (
<div className={wrapperClass}>
<iframe
src={embedUrl}
className="absolute inset-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
loading="lazy"
title="Vídeo de apresentação do imóvel"
/>
</div>
)
}
Fase 3 — Frontend Integração
3.1 frontend/src/types/property.ts
Adicionar em PropertyDetail:
export interface PropertyDetail extends Property {
address: string | null
code: string | null
description: string | null
video_url: string | null // NOVO
video_position: 'carousel' | 'section' // NOVO
}
3.2 frontend/src/components/PropertyDetail/PhotoCarousel.tsx
Mudanças necessárias:
- Importar
VideoPlayeregetEmbedUrl. - Adicionar
videoUrl?: string | nullemPhotoCarouselProps. - Calcular
hasVideo = !!videoUrl && getEmbedUrl(videoUrl).type !== 'unknown'. totalSlides = hasVideo ? photos.length + 1 : photos.length.- Ajustar
prev/nextpara usartotalSlides. - No slot de renderização do slide ativo: se
hasVideo && activeIndex === 0→<VideoPlayer url={videoUrl} />; caso contrário →<img>atual com índice ajustadophotos[hasVideo ? activeIndex - 1 : activeIndex]. - No strip de thumbnails: inserir no início (quando
hasVideo) um botão com ícone de play para o slide de vídeo. - Contador
{activeIndex + 1} / {totalSlides}.
Garantia de regressão: quando
videoUrlénull/undefined,hasVideo = falsee nenhuma lógica nova é exercida — comportamento idêntico ao atual.
3.3 frontend/src/pages/PropertyDetailPage.tsx
Substituir o uso atual do carrossel:
{/* Carousel + vídeo integrado (posição carousel) */}
<PhotoCarousel
photos={property.photos}
videoUrl={property.video_position === 'carousel' ? property.video_url : null}
/>
{/* Seção de vídeo (posição section) */}
{property.video_url && property.video_position === 'section' && (
<div>
<h2 className="text-base font-semibold text-textPrimary mb-3">
Vídeo de Apresentação
</h2>
<VideoPlayer url={property.video_url} />
</div>
)}
Adicionar import de
VideoPlayerno topo do arquivo.
3.4 frontend/src/pages/admin/PropertyForm.tsx
No formulário de edição, após o campo description, adicionar bloco de configuração de vídeo:
- Campo URL:
<input type="url">— label "URL do vídeo", placeholderhttps://youtube.com/watch?v=... - Select posição:
<select>— opções "Seção exclusiva (após carrossel)" (section) e "Integrado ao carrossel" (carousel); desabilitado quandovideo_urlestá vazio - Botão remover: visível apenas quando
video_urltem valor; zeravideo_urle resetavideo_positionpara'section' - Preview inline: debounce de 600ms; quando
getEmbedUrl(debouncedUrl).type !== 'unknown', renderiza<VideoPlayer url={debouncedUrl} className="mt-3" />
Campos adicionados ao estado/payload do form:
video_url: string // '' = sem vídeo
video_position: 'carousel' | 'section'
Complexity Tracking
Nenhuma violação da constituição detectada. Sem justificativas necessárias.
Checklist de Verificação Pré-Merge
alembic upgrade headexecuta sem erro;alembic downgrade -1reverte sem erro- Imóveis existentes (sem
video_url) continuam com página de detalhe inalterada - URL do YouTube: player embed exibido na posição correta
- URL do Vimeo: player embed exibido na posição correta
- URL
.mp4: player nativo exibido - URL inválida/desconhecida: sem seção de vídeo, sem erro no console
- String vazia via admin →
video_url = nullna API video_position = 'carousel': vídeo como slide 0 no carrosselvideo_position = 'section': seção "Vídeo de Apresentação" após carrossel- Preview admin exibe player com ≤ 600ms de debounce
- Layout mobile (< 768px) sem overflow
- Nenhuma nova dependência npm adicionada