sass-imobiliaria/specs/033-video-apresentacao-imovel/plan.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

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_position entra em _SCALAR_FIELDS normalmente. video_url precisa 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:

  1. Importar VideoPlayer e getEmbedUrl.
  2. Adicionar videoUrl?: string | null em PhotoCarouselProps.
  3. Calcular hasVideo = !!videoUrl && getEmbedUrl(videoUrl).type !== 'unknown'.
  4. totalSlides = hasVideo ? photos.length + 1 : photos.length.
  5. Ajustar prev/next para usar totalSlides.
  6. No slot de renderização do slide ativo: se hasVideo && activeIndex === 0<VideoPlayer url={videoUrl} />; caso contrário → <img> atual com índice ajustado photos[hasVideo ? activeIndex - 1 : activeIndex].
  7. No strip de thumbnails: inserir no início (quando hasVideo) um botão com ícone de play para o slide de vídeo.
  8. Contador {activeIndex + 1} / {totalSlides}.

Garantia de regressão: quando videoUrl é null/undefined, hasVideo = false e 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 VideoPlayer no 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:

  1. Campo URL: <input type="url"> — label "URL do vídeo", placeholder https://youtube.com/watch?v=...
  2. Select posição: <select> — opções "Seção exclusiva (após carrossel)" (section) e "Integrado ao carrossel" (carousel); desabilitado quando video_url está vazio
  3. Botão remover: visível apenas quando video_url tem valor; zera video_url e reseta video_position para 'section'
  4. 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 head executa sem erro; alembic downgrade -1 reverte 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 = null na API
  • video_position = 'carousel': vídeo como slide 0 no carrossel
  • video_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