38 KiB
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.pyConteúdo completo do arquivo a criar:
"""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 upgradedentro do container backend e confirmar que as colunas aparecem em\d propertiesno 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_urlevideo_positionao modelo SQLAlchemyProperty—backend/app/models/property.pyoldString (após
created_at, antes dephotos = db.relationship):created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now()) photos = db.relationship(newString:
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_urlevideo_positionemPropertyDetailOut—backend/app/schemas/property.pyoldString:
class PropertyDetailOut(PropertyOut): address: str | None = None code: str | None = None description: str | None = NonenewString:
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'Literaljá está importado emfrom typing import Literal(linha 6 do arquivo).
-
T004 [P] Adicionar
Literalao import, campos emPropertyAdminOute mapeamento emfrom_prop—backend/app/routes/admin.pyPasso 4a — Adicionar
Literalao import de typing:oldString:
from typing import OptionalnewString:
from typing import Optional, LiteralPasso 4b — Adicionar campos ao final do corpo de
PropertyAdminOut(antes do@classmethod):oldString:
is_active: bool is_featured: bool photos: list[PhotoAdminOut] = [] amenity_ids: list[int] = [] @classmethod def from_prop(cls, p: Property) -> "PropertyAdminOut":newString:
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(...)defrom_prop:oldString:
amenity_ids=[a.id for a in p.amenities], ) # ─── Imóveis ─────────────────────────────────────────────────────────────────newString:
amenity_ids=[a.id for a in p.amenities], video_url=p.video_url, video_position=p.video_position, ) # ─── Imóveis ─────────────────────────────────────────────────────────────────
-
T005 Adicionar
video_positionem_SCALAR_FIELDSe handler de sanitização devideo_urlemadmin_update_property—backend/app/routes/admin.pyPasso 5a — Adicionar
video_positionem_SCALAR_FIELDS:oldString:
"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:
"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/:idcom{"video_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "video_position": "section"}e confirmar queGET /api/properties/:slugretorna os camposvideo_urlevideo_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_urlevideo_positionà interfaceAdminProperty—frontend/src/pages/admin/AdminPropertiesPage.tsxoldString:
description: string | null is_active: boolean is_featured: boolean photos: AdminPhoto[] amenity_ids: number[] }newString:
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_urlevideo_positionà interfacePropertyFormData—frontend/src/pages/admin/PropertyForm.tsxoldString:
photos: PhotoItem[]; amenity_ids: number[]; }newString:
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 nohandleSubmit—frontend/src/pages/admin/PropertyForm.tsxPasso 8a — Adicionar estados após
description:oldString:
const [description, setDescription] = useState(initial?.description ?? ''); const [photos, setPhotos] = useState<PhotoItem[]>(initial?.photos ?? []);newString:
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
onSubmitdentro dehandleSubmit:oldString:
description: description.trim(), photos, amenity_ids: amenityIds, });newString:
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:
{/* ── Fotos ── */} <SectionDivider title="Fotos" />newString:
{/* ── 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_urlevideo_positionnoinitialprop ao abrir edição —frontend/src/pages/admin/AdminPropertiesPage.tsxoldString:
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:
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_urlpersiste. 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:
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_urlevideo_positionà interfacePropertyDetail—frontend/src/types/property.tsoldString:
export interface PropertyDetail extends Property { address: string | null code: string | null description: string | null }newString:
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.tsjá criado).Conteúdo completo do arquivo a criar:
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
videoUrlnoPhotoCarousel(slide 0 = vídeo, thumbnails, counter) —frontend/src/components/PropertyDetail/PhotoCarousel.tsxDepende de T012 (
VideoPlayer.tsxjá criado).Passo 13a — Adicionar import do
VideoPlayere atualizar interface:oldString:
import { useCallback, useEffect, useRef, useState } from 'react' import type { PropertyPhoto } from '../../types/property' interface PhotoCarouselProps { photos: PropertyPhoto[] }newString:
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
totalSlidese corrigirprev/next:oldString:
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:
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:
if (photos.length === 0) return <NoPhotoPlaceholder /> const active = photos[activeIndex] const single = photos.length === 1newString:
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 === 1Passo 13d — Substituir bloco do slide principal para suportar vídeo:
oldString:
{/* 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:
{/* 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:
{/* 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:
{/* 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:
{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:
{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
VideoPlayere lógica condicional de vídeo (carousel vs section) —frontend/src/pages/PropertyDetailPage.tsxDepende de T011 (tipo atualizado) e T012 (VideoPlayer criado).
Passo 14a — Adicionar import do
VideoPlayer:oldString:
import PhotoCarousel from '../components/PropertyDetail/PhotoCarousel';newString:
import PhotoCarousel from '../components/PropertyDetail/PhotoCarousel'; import VideoPlayer from '../components/PropertyDetail/VideoPlayer';Passo 14b — Passar
videoUrlaoPhotoCarousele renderizar seção exclusiva condicionalmente:oldString:
{/* Carousel */} <PhotoCarousel photos={property.photos} /> {/* Stats */}newString:
{/* 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 comvideo_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
getEmbedUrle bloco de preview em tempo real na seção de vídeo do formulário —frontend/src/pages/admin/PropertyForm.tsxDepende de T010 (
getEmbedUrl.ts) e T008 (UI de vídeo já presente).Passo 15a — Adicionar import:
oldString:
import api from '../../services/api'; import { useEffect, useRef, useState, useCallback } from 'react';newString:
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:
<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:
{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/cd frontend && npm run buildCritério de sucesso: Build finaliza com
✓ built in X.Xssem 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):
- T001 → T002 → T003 → T004 → T005 (backend)
- Em paralelo: T006 + T007 → T008 → T009 (US1 admin)
- Em paralelo: T010 + T011 → T012 → T013 → T014 (US2 visitante)
- 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 upgradeexecuta sem erros;flask db downgradereverte sem errosGET /api/properties/:slugretornavideo_url(string ou null) evideo_positionem todos os imóveisPUT /api/admin/properties/:idcom{"video_url": " https://youtu.be/abc ", "video_position": "carousel"}persistevideo_urlcom strip aplicadoPUT /api/admin/properties/:idcom{"video_url": ""}salvavideo_url = nullPUT /api/admin/properties/:idsemvideo_urlno 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 / 5para 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 }