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

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 propertiesbackend/migrations/versions/k3l4m5n6o7p8_add_video_to_properties.py

    Conteú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 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 Propertybackend/app/models/property.py

    oldString (após created_at, antes de photos = 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_url e video_position em PropertyDetailOutbackend/app/schemas/property.py

    oldString:

    class PropertyDetailOut(PropertyOut):
        address: str | None = None
        code: str | None = None
        description: str | None = None
    

    newString:

    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_propbackend/app/routes/admin.py

    Passo 4a — Adicionar Literal ao import de typing:

    oldString:

    from typing import Optional
    

    newString:

    from typing import Optional, Literal
    

    Passo 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(...) de from_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_position em _SCALAR_FIELDS e handler de sanitização de video_url em admin_update_propertybackend/app/routes/admin.py

    Passo 5a — Adicionar video_position em _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/: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 AdminPropertyfrontend/src/pages/admin/AdminPropertiesPage.tsx

    oldString:

        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_url e video_position à interface PropertyFormDatafrontend/src/pages/admin/PropertyForm.tsx

    oldString:

        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 no handleSubmitfrontend/src/pages/admin/PropertyForm.tsx

    Passo 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 onSubmit dentro de handleSubmit:

    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_url e video_position no initial prop ao abrir edição — frontend/src/pages/admin/AdminPropertiesPage.tsx

    oldString:

                        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_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 getEmbedUrlfrontend/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_url e video_position à interface PropertyDetailfrontend/src/types/property.ts

    oldString:

    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 VideoPlayerfrontend/src/components/PropertyDetail/VideoPlayer.tsx [NOVO]

    Depende de T010 (getEmbedUrl.ts já 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 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:

    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 totalSlides e corrigir prev/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 === 1
    

    newString:

        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:

                {/* 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 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:

    import PhotoCarousel from '../components/PropertyDetail/PhotoCarousel';
    

    newString:

    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:

                                        {/* 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 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:

    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 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 }