chore: seed sample video data, add spec 033 and update instructions
This commit is contained in:
parent
2e9f903d06
commit
e1a1f71fbd
11 changed files with 1911 additions and 2 deletions
352
specs/033-video-apresentacao-imovel/plan.md
Normal file
352
specs/033-video-apresentacao-imovel/plan.md
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
# Implementation Plan: Vídeo de Apresentação do Imóvel
|
||||
|
||||
**Branch**: `033-video-apresentacao-imovel` | **Data**: 2026-04-22 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: [specs/033-video-apresentacao-imovel/spec.md](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)
|
||||
|
||||
```text
|
||||
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
|
||||
|
||||
```text
|
||||
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`
|
||||
|
||||
```python
|
||||
"""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`:
|
||||
|
||||
```python
|
||||
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`:
|
||||
|
||||
```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'
|
||||
```
|
||||
|
||||
#### 1.4 Schema admin + handler — `backend/app/routes/admin.py`
|
||||
|
||||
**`PropertyAdminOut`** — adicionar campos:
|
||||
```python
|
||||
video_url: str | None = None
|
||||
video_position: Literal['carousel', 'section'] = 'section'
|
||||
```
|
||||
|
||||
**`PropertyAdminOut.from_prop`** — adicionar no `return cls(...)`:
|
||||
```python
|
||||
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`:
|
||||
|
||||
```python
|
||||
# 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
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```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 (!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`:
|
||||
```ts
|
||||
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:
|
||||
|
||||
```tsx
|
||||
{/* 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**:
|
||||
```ts
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue