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
4
.github/copilot-instructions.md
vendored
4
.github/copilot-instructions.md
vendored
|
|
@ -21,6 +21,8 @@ Auto-generated from all feature plans. Last updated: 2026-04-22
|
|||
- PostgreSQL 16 — sem novas tabelas; property_count calculado via outerjoin COUNT (feature 024)
|
||||
- TypeScript 5.5 (frontend principal) / Python 3.12 existente sem mudança funcional + React 18, react-router-dom v6, Tailwind CSS 3.4, Axios (indireto via autenticação), contexto próprio `useAuth`, `useFavorites`, `ThemeToggle` (030-navbar-topo-ux)
|
||||
- N/A para persistência nova; sessão e token continuam em `localStorage` via `AuthContext` (030-navbar-topo-ux)
|
||||
- Python 3.12 (backend) · TypeScript 5.5 (frontend) + Flask 3.x, SQLAlchemy 2.x, Pydantic v2, Alembic (backend) · React 18, Tailwind CSS 3.4 (frontend) — **sem novas dependências** (main)
|
||||
- PostgreSQL 16 — tabela `properties` recebe `video_url VARCHAR(512) NULL` e `video_position VARCHAR(20) NOT NULL DEFAULT 'section'` (main)
|
||||
|
||||
- Python 3.12 / TypeScript 5.5 + Flask 3.x, SQLAlchemy 2.x, Pydantic v2 (backend) · React 18, Tailwind CSS 3.4, react-router-dom v6, Axios (frontend) (master)
|
||||
|
||||
|
|
@ -41,9 +43,9 @@ cd src; pytest; ruff check .
|
|||
Python 3.12 / TypeScript 5.5: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- main: Added Python 3.12 (backend) · TypeScript 5.5 (frontend) + Flask 3.x, SQLAlchemy 2.x, Pydantic v2, Alembic (backend) · React 18, Tailwind CSS 3.4 (frontend) — **sem novas dependências**
|
||||
- 030-navbar-topo-ux: Added TypeScript 5.5 (frontend principal) / Python 3.12 existente sem mudança funcional + React 18, react-router-dom v6, Tailwind CSS 3.4, Axios (indireto via autenticação), contexto próprio `useAuth`, `useFavorites`, `ThemeToggle`
|
||||
- master: Added Python 3.12 + Flask SQLAlchemy func.count subquery · React 18 FilterSidebar com busca cross-categoria, controlled accordion, truncamento top-5 (feature 024)
|
||||
- master: Added Python 3.12 (backend) · TypeScript 5.5 (frontend) + Flask 3.x, SQLAlchemy 2.x, Pydantic v2 (backend) · React 18, Tailwind CSS 3.4, react-router-dom v6, Axios (frontend)
|
||||
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"feature_directory": "specs/029-ux-area-do-cliente"
|
||||
"feature_directory": "specs/033-video-apresentacao-imovel"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -316,6 +316,8 @@ SAMPLE_PROPERTIES = [
|
|||
"area_m2": 98,
|
||||
"is_featured": True,
|
||||
"is_active": True,
|
||||
"video_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"video_position": "section",
|
||||
"amenity_slugs": [
|
||||
"ar-condicionado",
|
||||
"armario-embutido",
|
||||
|
|
@ -346,6 +348,8 @@ SAMPLE_PROPERTIES = [
|
|||
"area_m2": 220,
|
||||
"is_featured": True,
|
||||
"is_active": True,
|
||||
"video_url": "https://www.youtube.com/watch?v=ysz5S6PUM-U",
|
||||
"video_position": "carousel",
|
||||
"amenity_slugs": ["piscina", "churrasqueira", "jardim", "quintal"],
|
||||
"photos": [
|
||||
{
|
||||
|
|
@ -396,6 +400,8 @@ SAMPLE_PROPERTIES = [
|
|||
"area_m2": 380,
|
||||
"is_featured": True,
|
||||
"is_active": True,
|
||||
"video_url": "https://vimeo.com/76979871",
|
||||
"video_position": "section",
|
||||
"amenity_slugs": [
|
||||
"piscina",
|
||||
"vista-panoramica",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
# Specification Quality Checklist: Vídeo de Apresentação do Imóvel
|
||||
|
||||
**Purpose**: Validar completude e qualidade da especificação antes de avançar para o planejamento
|
||||
**Created**: 2026-04-22
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Spec está pronta para avançar para `/speckit.plan`
|
||||
- Escopo claramente delimitado: 1 vídeo por imóvel, sem upload direto, sem múltiplos vídeos
|
||||
- Posição padrão documentada como "seção exclusiva" nas Assumptions
|
||||
- Comportamento de edge cases (URL inválida, vídeo removido externamente) está coberto
|
||||
146
specs/033-video-apresentacao-imovel/contracts/api.md
Normal file
146
specs/033-video-apresentacao-imovel/contracts/api.md
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# Contracts: Vídeo de Apresentação do Imóvel (033)
|
||||
|
||||
**Gerado por**: /speckit.plan
|
||||
**Data**: 2026-04-22
|
||||
|
||||
---
|
||||
|
||||
## Endpoint Afetado: PUT /api/admin/properties/:id
|
||||
|
||||
**Autenticação**: JWT admin obrigatório (Bearer token)
|
||||
|
||||
### Request Body — Campos adicionados
|
||||
|
||||
```json
|
||||
{
|
||||
"video_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"video_position": "section"
|
||||
}
|
||||
```
|
||||
|
||||
| Campo | Tipo | Obrigatório | Valores Aceitos | Default |
|
||||
|-------|------|-------------|-----------------|---------|
|
||||
| `video_url` | `string \| null` | Não | Qualquer string válida ou `null` | Campo não enviado = não alterado |
|
||||
| `video_position` | `string` | Não | `"carousel"` \| `"section"` | `"section"` |
|
||||
|
||||
**Comportamento especial**:
|
||||
- `video_url: ""` (string vazia) → equivalente a `null` (remove o vídeo)
|
||||
- `video_url` com espaços → sanitizado via `.strip()` antes de persistir
|
||||
- Omitir `video_url` e `video_position` → campos não são alterados (comportamento PUT parcial existente)
|
||||
|
||||
### Response Body — Campos adicionados
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "...",
|
||||
"video_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"video_position": "section",
|
||||
"..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoint Afetado: GET /api/properties/:slug
|
||||
|
||||
### Response Body — Campos adicionados em `PropertyDetailOut`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"slug": "apartamento-centro",
|
||||
"title": "...",
|
||||
"video_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"video_position": "section",
|
||||
"photos": [...],
|
||||
"..."
|
||||
}
|
||||
```
|
||||
|
||||
| Campo | Tipo | Nullable | Descrição |
|
||||
|-------|------|----------|-----------|
|
||||
| `video_url` | `string \| null` | Sim | URL do vídeo ou `null` se não configurado |
|
||||
| `video_position` | `"carousel" \| "section"` | Não | Posição de exibição; default `"section"` |
|
||||
|
||||
---
|
||||
|
||||
## Contrato Frontend — Utilitário `getEmbedUrl`
|
||||
|
||||
**Arquivo**: `frontend/src/utils/getEmbedUrl.ts`
|
||||
|
||||
```ts
|
||||
export type VideoType = 'youtube' | 'vimeo' | 'direct' | 'unknown'
|
||||
|
||||
export interface EmbedResult {
|
||||
type: VideoType
|
||||
embedUrl: string | null
|
||||
}
|
||||
|
||||
export function getEmbedUrl(url: string): EmbedResult
|
||||
```
|
||||
|
||||
### Tabela de transformação
|
||||
|
||||
| URL de entrada | `type` | `embedUrl` |
|
||||
|----------------|--------|-----------|
|
||||
| `https://www.youtube.com/watch?v=VIDEO_ID` | `youtube` | `https://www.youtube.com/embed/VIDEO_ID` |
|
||||
| `https://youtu.be/VIDEO_ID` | `youtube` | `https://www.youtube.com/embed/VIDEO_ID` |
|
||||
| `https://www.youtube.com/embed/VIDEO_ID` | `youtube` | URL original (já é embed) |
|
||||
| `https://vimeo.com/VIDEO_ID` | `vimeo` | `https://player.vimeo.com/video/VIDEO_ID` |
|
||||
| `https://player.vimeo.com/video/VIDEO_ID` | `vimeo` | URL original (já é embed) |
|
||||
| `https://example.com/video.mp4` | `direct` | URL original |
|
||||
| `https://example.com/video.webm` | `direct` | URL original |
|
||||
| Qualquer outro formato | `unknown` | `null` |
|
||||
|
||||
---
|
||||
|
||||
## Contrato Frontend — Componente `VideoPlayer`
|
||||
|
||||
**Arquivo**: `frontend/src/components/PropertyDetail/VideoPlayer.tsx`
|
||||
|
||||
```ts
|
||||
interface VideoPlayerProps {
|
||||
url: string
|
||||
className?: string
|
||||
}
|
||||
```
|
||||
|
||||
### Comportamento de renderização
|
||||
|
||||
| `type` retornado por `getEmbedUrl` | Renderização |
|
||||
|------------------------------------|-------------|
|
||||
| `youtube` \| `vimeo` | `<iframe src={embedUrl} allowFullScreen loading="lazy" />` dentro de wrapper 16:9 |
|
||||
| `direct` | `<video src={url} controls />` dentro de wrapper 16:9 |
|
||||
| `unknown` | `null` (nada renderizado) |
|
||||
|
||||
### Atributos iframe obrigatórios
|
||||
```html
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
title="Vídeo de apresentação do imóvel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contrato Frontend — Modificação `PhotoCarousel`
|
||||
|
||||
**Arquivo**: `frontend/src/components/PropertyDetail/PhotoCarousel.tsx`
|
||||
|
||||
```ts
|
||||
interface PhotoCarouselProps {
|
||||
photos: PropertyPhoto[]
|
||||
videoUrl?: string | null // NOVO — opcional
|
||||
}
|
||||
```
|
||||
|
||||
### Comportamento quando `videoUrl` está presente
|
||||
- `totalSlides = photos.length + 1` (slide 0 = vídeo)
|
||||
- Slide 0: renderiza `<VideoPlayer url={videoUrl} />`
|
||||
- Slides 1..N: renderizam fotos normalmente
|
||||
- Contador: `{activeIndex + 1} / {totalSlides}` (ex.: `1 / 5` para o vídeo)
|
||||
- Thumbnail de vídeo no strip: ícone de play com `aria-label="Ver vídeo"`
|
||||
|
||||
### Comportamento quando `videoUrl` é null/undefined
|
||||
- Comportamento idêntico ao atual (sem alteração)
|
||||
95
specs/033-video-apresentacao-imovel/data-model.md
Normal file
95
specs/033-video-apresentacao-imovel/data-model.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# Data Model: Vídeo de Apresentação do Imóvel (033)
|
||||
|
||||
**Gerado por**: /speckit.plan
|
||||
**Data**: 2026-04-22
|
||||
|
||||
---
|
||||
|
||||
## Entidade Afetada: `Property` (tabela `properties`)
|
||||
|
||||
### Novos Campos
|
||||
|
||||
| Coluna | Tipo SQL | Nullable | Default | Restrições |
|
||||
|--------|----------|----------|---------|-----------|
|
||||
| `video_url` | `VARCHAR(512)` | ✅ NULL | `NULL` | — |
|
||||
| `video_position` | `VARCHAR(20)` | ❌ NOT NULL | `'section'` | valores válidos: `'carousel'` \| `'section'` (enforced no Pydantic) |
|
||||
|
||||
### Justificativa de tipos
|
||||
- `VARCHAR(512)` para `video_url`: URLs YouTube/Vimeo raramente ultrapassam 100 chars, mas URLs diretas de CDN podem ser longas; 512 é conservador sem overhead relevante.
|
||||
- `VARCHAR(20)` para `video_position`: valores `'carousel'` (8 chars) e `'section'` (7 chars) cabem confortavelmente; sem ENUM SQL para simplificar migrations.
|
||||
|
||||
---
|
||||
|
||||
## Migration Alembic
|
||||
|
||||
**Arquivo**: `backend/migrations/versions/k3l4m5n6o7p8_add_video_to_properties.py`
|
||||
**Antecessora**: `j2k3l4m5n6o7_add_homepage_hero_theme_images.py`
|
||||
|
||||
```python
|
||||
# upgrade
|
||||
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'))
|
||||
|
||||
# downgrade
|
||||
op.drop_column('properties', 'video_position')
|
||||
op.drop_column('properties', 'video_url')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schema Pydantic — Campos Adicionados
|
||||
|
||||
### `PropertyDetailOut` (backend/app/schemas/property.py)
|
||||
```python
|
||||
video_url: str | None = None
|
||||
video_position: Literal['carousel', 'section'] = 'section'
|
||||
```
|
||||
|
||||
### `PropertyAdminOut` (backend/app/routes/admin.py)
|
||||
```python
|
||||
video_url: str | None = None
|
||||
video_position: Literal['carousel', 'section'] = 'section'
|
||||
```
|
||||
|
||||
### `_SCALAR_FIELDS` no `admin_update_property`
|
||||
Adicionar `'video_url'` e `'video_position'` à tupla `_SCALAR_FIELDS`.
|
||||
**Sanitização**: se `'video_url'` estiver presente no body, aplicar `.strip()` e tratar string vazia como `None`.
|
||||
|
||||
---
|
||||
|
||||
## Tipos TypeScript
|
||||
|
||||
### `PropertyDetail` (frontend/src/types/property.ts)
|
||||
```ts
|
||||
video_url: string | null
|
||||
video_position: 'carousel' | 'section'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validação de Regras
|
||||
|
||||
| Regra | Onde Validado |
|
||||
|-------|--------------|
|
||||
| `video_position` só aceita `'carousel'` ou `'section'` | Pydantic `Literal` (backend) |
|
||||
| `video_url` string vazia → `None` | handler `admin_update_property` (backend) |
|
||||
| `video_url` sem espaços | `.strip()` antes de persistir (backend) |
|
||||
| URL inválida (domínio não suportado) → sem exibição | `getEmbedUrl` retorna `type: 'unknown'` (frontend) |
|
||||
|
||||
---
|
||||
|
||||
## Estado de Transição
|
||||
|
||||
```
|
||||
Property sem vídeo (video_url = NULL)
|
||||
→ Nenhuma alteração visual na página de detalhe
|
||||
|
||||
Property com video_position = 'section'
|
||||
→ Seção "Vídeo de Apresentação" renderizada após carrossel
|
||||
|
||||
Property com video_position = 'carousel'
|
||||
→ VideoPlayer como índice 0 no PhotoCarousel
|
||||
|
||||
Remover vídeo (admin)
|
||||
→ video_url = NULL, video_position retorna ao default 'section'
|
||||
```
|
||||
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
|
||||
110
specs/033-video-apresentacao-imovel/quickstart.md
Normal file
110
specs/033-video-apresentacao-imovel/quickstart.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# Quickstart: Vídeo de Apresentação do Imóvel (033)
|
||||
|
||||
**Gerado por**: /speckit.plan
|
||||
**Data**: 2026-04-22
|
||||
|
||||
---
|
||||
|
||||
## Setup do Ambiente
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
uv sync
|
||||
alembic upgrade head # aplica k3l4m5n6o7p8_add_video_to_properties
|
||||
|
||||
# Frontend
|
||||
cd frontend
|
||||
npm install # sem novas dependências nesta feature
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rodando Localmente
|
||||
|
||||
```bash
|
||||
# Do root do projeto
|
||||
docker compose up # ou: start.ps1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testando o Fluxo Completo
|
||||
|
||||
### 1. Configurar vídeo via admin
|
||||
|
||||
```bash
|
||||
# Autenticar e obter token
|
||||
TOKEN=$(curl -s -X POST http://localhost:5000/api/admin/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"admin@example.com","password":"senha"}' | jq -r .access_token)
|
||||
|
||||
# Atualizar imóvel com vídeo
|
||||
curl -X PUT http://localhost:5000/api/admin/properties/<UUID> \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"video_url":"https://www.youtube.com/watch?v=dQw4w9WgXcQ","video_position":"section"}'
|
||||
```
|
||||
|
||||
### 2. Verificar retorno da API pública
|
||||
|
||||
```bash
|
||||
curl http://localhost:5000/api/properties/<SLUG> | jq '.video_url, .video_position'
|
||||
# Esperado: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" e "section"
|
||||
```
|
||||
|
||||
### 3. Verificar exibição na página
|
||||
|
||||
Abrir `http://localhost:5173/imoveis/<SLUG>` e confirmar:
|
||||
- Seção "Vídeo de Apresentação" visível abaixo do carrossel
|
||||
- Player do YouTube incorporado
|
||||
- Sem erros no console
|
||||
|
||||
### 4. Testar posição carrossel
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:5000/api/admin/properties/<UUID> \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"video_position":"carousel"}'
|
||||
```
|
||||
|
||||
Recarregar a página: vídeo deve aparecer como primeiro slide no carrossel.
|
||||
|
||||
### 5. Remover vídeo
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:5000/api/admin/properties/<UUID> \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"video_url":""}'
|
||||
```
|
||||
|
||||
Confirmar que `video_url` retorna `null` na API pública e a seção de vídeo desaparece.
|
||||
|
||||
---
|
||||
|
||||
## Rodando os Testes
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pytest tests/ -v -k "video" # testes específicos da feature (quando criados)
|
||||
pytest tests/ -v # suite completa
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Arquivos Criados/Modificados
|
||||
|
||||
| Arquivo | Ação |
|
||||
|---------|------|
|
||||
| `backend/migrations/versions/k3l4m5n6o7p8_add_video_to_properties.py` | NOVO |
|
||||
| `backend/app/models/property.py` | MODIFICADO — 2 campos novos |
|
||||
| `backend/app/schemas/property.py` | MODIFICADO — `PropertyDetailOut` |
|
||||
| `backend/app/routes/admin.py` | MODIFICADO — `PropertyAdminOut`, `_SCALAR_FIELDS`, sanitização |
|
||||
| `frontend/src/utils/getEmbedUrl.ts` | NOVO |
|
||||
| `frontend/src/components/PropertyDetail/VideoPlayer.tsx` | NOVO |
|
||||
| `frontend/src/components/PropertyDetail/PhotoCarousel.tsx` | MODIFICADO — prop `videoUrl` |
|
||||
| `frontend/src/pages/PropertyDetailPage.tsx` | MODIFICADO — renderização condicional |
|
||||
| `frontend/src/pages/admin/PropertyForm.tsx` | MODIFICADO — campo URL + select posição + preview |
|
||||
| `frontend/src/types/property.ts` | MODIFICADO — `PropertyDetail` |
|
||||
44
specs/033-video-apresentacao-imovel/research.md
Normal file
44
specs/033-video-apresentacao-imovel/research.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Research: Vídeo de Apresentação do Imóvel (033)
|
||||
|
||||
**Gerado por**: /speckit.plan
|
||||
**Data**: 2026-04-22
|
||||
|
||||
---
|
||||
|
||||
## Decisão 1 — Estratégia de embed de vídeo
|
||||
|
||||
**Decision**: Utilitário puro `getEmbedUrl` no frontend (sem lógica no backend) que faz parse client-side.
|
||||
**Rationale**: O backend não precisa transformar URLs — salva o valor bruto e devolve sem alteração. O parse pode falhar silenciosamente no cliente sem impactar dados. Reduz complexidade no servidor.
|
||||
**Alternatives considered**: Validar domínio no backend via Pydantic `@field_validator` — rejeitado porque geraria 422 em URLs válidas de domínios futuros; validação de formato basta no backend.
|
||||
|
||||
---
|
||||
|
||||
## Decisão 2 — Posição do vídeo: `VARCHAR(20)` vs `ENUM` SQL
|
||||
|
||||
**Decision**: `VARCHAR(20) NOT NULL DEFAULT 'section'` — sem ENUM SQL.
|
||||
**Rationale**: O projeto já usa `db.Enum` apenas para `property_type` onde os valores são críticos para queries. Para posição de vídeo, a validação é feita no Pydantic (`Literal['carousel', 'section']`); usar ENUM SQL adicionaria complexidade em migrations sem benefício mensurável.
|
||||
**Alternatives considered**: `db.Enum('carousel', 'section', name='video_position')` — rejeitado por custo de migration mais alto.
|
||||
|
||||
---
|
||||
|
||||
## Decisão 3 — Integração do vídeo no carrossel vs componente separado
|
||||
|
||||
**Decision**: Modificar `PhotoCarousel` para aceitar `videoUrl?: string | null` e renderizar o `VideoPlayer` como slide virtual no índice 0 quando a posição for `carousel`. Para posição `section`, renderizar `<VideoPlayer>` fora do carrossel em `PropertyDetailPage`.
|
||||
**Rationale**: Reuso do sistema de navegação (setas, dots, touch swipe) já testado no carrossel. Não cria um segundo carrossel, não duplica lógica de gestos.
|
||||
**Alternatives considered**: Criar carrossel separado `MediaCarousel` — rejeitado por YAGNI (sobrecomplexidade para suportar um único slide de vídeo).
|
||||
|
||||
---
|
||||
|
||||
## Decisão 4 — Sanitização de URL no backend
|
||||
|
||||
**Decision**: Aplicar `str.strip()` antes de persistir e tratar string vazia como `None`.
|
||||
**Rationale**: Previne persistência de URLs com espaços acidentais (edge case da spec). Simples de implementar no update handler existente, sem dependência externa.
|
||||
**Alternatives considered**: Pydantic `AnyUrl` — rejeitado porque URLs de arquivos diretos (ex.: CDN sem `https://`) precisam ser aceitas; `AnyUrl` restringiria demais.
|
||||
|
||||
---
|
||||
|
||||
## Decisão 5 — Preview no admin (debounce)
|
||||
|
||||
**Decision**: Debounce de 600ms no campo de URL; o preview re-renderiza apenas quando `getEmbedUrl` retorna tipo != `'unknown'`.
|
||||
**Rationale**: Evita re-renders a cada keystroke enquanto o administrador digita. Valor de 600ms é suficiente para não parecer lento.
|
||||
**Alternatives considered**: Preview apenas on-blur — rejeitado porque a spec exige "em tempo real" (FR-011).
|
||||
144
specs/033-video-apresentacao-imovel/spec.md
Normal file
144
specs/033-video-apresentacao-imovel/spec.md
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
# Feature Specification: Vídeo de Apresentação do Imóvel
|
||||
|
||||
**Feature Branch**: `033-video-apresentacao-imovel`
|
||||
**Created**: 2026-04-22
|
||||
**Status**: Draft
|
||||
|
||||
---
|
||||
|
||||
## Contexto
|
||||
|
||||
A página de detalhe de imóvel oferece atualmente galeria de fotos, dados técnicos e descrição textual. Não existe suporte a vídeo — um recurso cada vez mais esperado em plataformas imobiliárias, pois vídeos de tour virtual ou apresentação aumentam o engajamento do visitante e reduzem a necessidade de visitas presenciais desnecessárias.
|
||||
|
||||
Esta spec cobre a adição de suporte a um vídeo de apresentação por imóvel, com configuração via painel admin e exibição na página pública de detalhe. O administrador pode escolher se o vídeo aparece integrado ao carrossel de fotos (como primeiro slide) ou em uma seção exclusiva logo abaixo do carrossel.
|
||||
|
||||
---
|
||||
|
||||
## User Scenarios & Testing
|
||||
|
||||
### User Story 1 — Administrador Configura Vídeo em um Imóvel (Priority: P1)
|
||||
|
||||
O administrador da imobiliária acessa o painel de edição de um imóvel, informa a URL de um vídeo (YouTube, Vimeo ou arquivo direto), escolhe onde ele será exibido na página pública e salva as alterações.
|
||||
|
||||
**Why this priority**: É o ponto de entrada de toda a feature. Sem a capacidade de configurar o vídeo no admin, nenhuma das outras histórias se torna possível.
|
||||
|
||||
**Independent Test**: Autenticar como administrador, acessar a edição de um imóvel existente, preencher o campo de URL de vídeo com uma URL válida do YouTube (ex.: `https://www.youtube.com/watch?v=dQw4w9WgXcQ`), selecionar a posição "Seção exclusiva de vídeo" e salvar. Verificar via API que o imóvel retorna `video_url` preenchido e `video_position` igual a `"section"`.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** um administrador autenticado na página de edição de um imóvel, **When** ele informa uma URL válida do YouTube e clica em "Salvar", **Then** o imóvel é atualizado com a URL informada e a posição padrão aplicada.
|
||||
2. **Given** um administrador configurando o vídeo, **When** ele altera a posição para "No carrossel de fotos", **Then** o valor salvo reflete a posição selecionada.
|
||||
3. **Given** um administrador que deseja remover o vídeo de um imóvel, **When** ele clica em "Remover vídeo" e confirma, **Then** a URL e a posição são apagadas e o imóvel volta ao estado sem vídeo.
|
||||
4. **Given** um administrador informando uma URL do Vimeo, **When** ele salva o imóvel, **Then** a URL é persistida corretamente sem transformação ou truncamento.
|
||||
5. **Given** um usuário não autenticado tentando editar um imóvel, **When** a requisição é enviada, **Then** o sistema retorna erro de acesso não autorizado (HTTP 401).
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 — Visitante Assiste ao Vídeo na Página de Detalhe (Priority: P1)
|
||||
|
||||
Um visitante acessa a página de detalhe de um imóvel que possui vídeo configurado e consegue assistir ao vídeo sem sair da página.
|
||||
|
||||
**Why this priority**: É o produto final entregue ao usuário final do site — a razão de existir de toda a feature. Sem a exibição correta, a configuração feita pelo admin não gera valor.
|
||||
|
||||
**Independent Test**: Acessar diretamente a URL de detalhe de um imóvel que tem `video_url` e `video_position` configurados. Verificar que o player de vídeo é exibido no local correto (carrossel ou seção separada) e que o vídeo pode ser iniciado pela interação do visitante.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** um imóvel com `video_position` igual a `"section"`, **When** um visitante acessa a página de detalhe, **Then** uma seção "Vídeo de Apresentação" é exibida após o carrossel de fotos, contendo o player incorporado do vídeo.
|
||||
2. **Given** um imóvel com `video_position` igual a `"carousel"`, **When** um visitante acessa a página de detalhe, **Then** o vídeo aparece como primeiro item no carrossel de fotos, antes das imagens.
|
||||
3. **Given** um imóvel com URL do YouTube configurada, **When** o visitante visualiza a página, **Then** o vídeo é exibido como player incorporado do YouTube, sem redirecionar o visitante para outra aba ou página.
|
||||
4. **Given** um imóvel com URL do Vimeo configurada, **When** o visitante visualiza a página, **Then** o vídeo é exibido como player incorporado do Vimeo.
|
||||
5. **Given** um imóvel com URL de arquivo de vídeo direto (`.mp4` ou `.webm`), **When** o visitante visualiza a página, **Then** o vídeo é exibido via player nativo do navegador.
|
||||
6. **Given** um visitante em dispositivo móvel, **When** ele acessa um imóvel com vídeo, **Then** o player de vídeo se adapta ao layout da tela sem overflow ou quebra de layout.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 — Administrador Pré-visualiza o Vídeo Antes de Salvar (Priority: P2)
|
||||
|
||||
O administrador informa uma URL de vídeo na interface de edição do imóvel e consegue pré-visualizar o player embutido antes de salvar as alterações.
|
||||
|
||||
**Why this priority**: Reduz erros de configuração (URL errada, vídeo equivocado) sem depender de verificação manual na página pública após o salvamento.
|
||||
|
||||
**Independent Test**: Na tela de edição de imóvel no admin, informar uma URL válida do YouTube no campo de vídeo. Verificar que um player de preview é exibido na própria tela de edição, sem necessidade de salvar.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** um administrador digitando uma URL do YouTube no campo de vídeo, **When** a URL é reconhecida como válida, **Then** um player de preview é exibido abaixo do campo na mesma tela de edição.
|
||||
2. **Given** um administrador com preview ativo, **When** ele altera a URL por outra URL válida, **Then** o preview é atualizado para refletir o novo vídeo.
|
||||
3. **Given** um administrador digitando uma URL inválida (ex.: texto aleatório), **When** a URL não pode ser interpretada como vídeo, **Then** o preview não é exibido e nenhum erro de exceção é lançado na interface.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 — Visitante Acessa Imóvel Sem Vídeo — Experiência Inalterada (Priority: P3)
|
||||
|
||||
Um visitante acessa um imóvel que não possui vídeo configurado e a página de detalhe exibe apenas o carrossel de fotos e as informações textuais, sem espaços vazios ou erros relacionados ao vídeo.
|
||||
|
||||
**Why this priority**: Garante que a nova feature não introduz regressão para a grande maioria dos imóveis que não terão vídeo imediatamente.
|
||||
|
||||
**Independent Test**: Acessar a URL de detalhe de qualquer imóvel que não tenha `video_url` configurado. Verificar que a página exibe normalmente o carrossel de fotos, dados e descrição, sem seção de vídeo vazia, sem mensagens de erro e sem degradação visual.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** um imóvel sem vídeo configurado, **When** um visitante acessa sua página de detalhe, **Then** a seção "Vídeo de Apresentação" não é exibida em nenhuma parte da página.
|
||||
2. **Given** um imóvel sem vídeo, **When** o carrossel de fotos é renderizado, **Then** o primeiro slide é uma foto (não um slide de vídeo vazio).
|
||||
3. **Given** qualquer imóvel existente antes desta feature, **When** acessado após o deploy, **Then** sua página de detalhe se comporta exatamente como antes, sem alterações visuais indesejadas.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- O que acontece se o administrador salvar uma URL de vídeo com espaços ou caracteres especiais não codificados? O sistema deve sanitizar a URL antes de persistir.
|
||||
- O que acontece se o visitante tenta acessar um imóvel cuja URL de vídeo aponta para um vídeo removido ou privado da plataforma? O player do serviço externo (YouTube/Vimeo) exibirá sua própria mensagem de erro; a página do imóvel não deve quebrar.
|
||||
- O que acontece se o visitante estiver com bloqueador de scripts que impeça o carregamento dos iframes? O layout da página não deve quebrar; o espaço do player pode ser omitido ou substituído por mensagem informativa.
|
||||
- O que acontece se a URL informada for de um domínio não suportado (nem YouTube, nem Vimeo, nem extensão de vídeo)? O sistema deve ignorar o embed e não exibir o player, sem expor erros técnicos ao visitante.
|
||||
- O que acontece com imóveis cujo `video_url` é uma string vazia após um "remover vídeo"? O sistema deve tratar string vazia e `null` de forma equivalente — nenhum vídeo é exibido.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: O sistema DEVE permitir ao administrador associar uma URL de vídeo a um imóvel via painel de edição.
|
||||
- **FR-002**: O sistema DEVE aceitar URLs dos formatos: `youtube.com/watch?v=`, `youtu.be/`, `youtube.com/embed/`, `vimeo.com/` e URLs diretas terminadas em `.mp4` ou `.webm`.
|
||||
- **FR-003**: O sistema DEVE permitir ao administrador selecionar a posição de exibição do vídeo: "no carrossel de fotos" (primeiro slide) ou "seção exclusiva após o carrossel".
|
||||
- **FR-004**: O sistema DEVE permitir ao administrador remover o vídeo de um imóvel, retornando o imóvel ao estado sem vídeo.
|
||||
- **FR-005**: O campo de vídeo DEVE ser opcional; imóveis sem vídeo configurado não devem ser afetados.
|
||||
- **FR-006**: A página de detalhe DEVE exibir o player de vídeo incorporado (embed) quando o imóvel tiver vídeo do YouTube ou Vimeo configurado.
|
||||
- **FR-007**: A página de detalhe DEVE exibir um player nativo de vídeo quando a URL for de um arquivo `.mp4` ou `.webm`.
|
||||
- **FR-008**: Quando `video_position` for "carrossel", o vídeo DEVE aparecer como primeiro item no carrossel, antes das fotos.
|
||||
- **FR-009**: Quando `video_position` for "seção", DEVE ser exibida uma seção "Vídeo de Apresentação" após o carrossel de fotos.
|
||||
- **FR-010**: Uma URL inválida ou de domínio não suportado NÃO DEVE causar erro de exceção ou quebra visual na página de detalhe.
|
||||
- **FR-011**: O painel admin DEVE exibir um preview do player de vídeo em tempo real enquanto o administrador digita uma URL válida.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Imóvel (Property)**: Entidade central da plataforma. Recebe dois novos atributos opcionais:
|
||||
- `video_url`: endereço do vídeo de apresentação (pode ser nulo/ausente)
|
||||
- `video_position`: posição de exibição do vídeo na página de detalhe — assume dois valores possíveis ("carrossel" ou "seção exclusiva"); o valor padrão é "seção exclusiva" quando não especificado
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: O administrador consegue adicionar ou remover um vídeo de um imóvel em menos de 1 minuto a partir da tela de edição.
|
||||
- **SC-002**: 100% dos imóveis com `video_url` válida exibem o player de vídeo correto na posição configurada, sem erros de renderização.
|
||||
- **SC-003**: 100% dos imóveis sem `video_url` continuam com a página de detalhe idêntica ao estado anterior ao deploy desta feature.
|
||||
- **SC-004**: O visitante consegue iniciar a reprodução do vídeo sem sair da página de detalhe do imóvel.
|
||||
- **SC-005**: URLs inválidas ou de domínios não suportados não causam erros visíveis ao visitante em 100% dos casos.
|
||||
- **SC-006**: O preview do vídeo no painel admin é exibido em menos de 3 segundos após o administrador informar uma URL válida.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Cada imóvel suporta no máximo um vídeo nesta versão; múltiplos vídeos por imóvel estão fora do escopo.
|
||||
- A validação de disponibilidade do vídeo (verificar se o link não está quebrado) não é realizada em tempo real; a responsabilidade de manter o link ativo é do administrador.
|
||||
- O embed de vídeo de plataformas externas (YouTube, Vimeo) está sujeito às políticas de privacidade e cookies dessas plataformas; conformidade com LGPD referente a cookies de terceiros pode exigir ação futura independente desta feature.
|
||||
- A posição padrão do vídeo, quando não especificada, é "seção exclusiva após o carrossel".
|
||||
- O player de vídeo segue os padrões de responsividade já adotados no projeto (proporção 16:9, largura 100% do contêiner).
|
||||
- Não há suporte a vídeo hospedado diretamente no servidor da imobiliária nesta versão; apenas links externos ou URLs de arquivos públicos.
|
||||
- O endpoint admin de edição de imóvel já existente será estendido para aceitar os novos campos; não há criação de novo endpoint.
|
||||
- As alterações de banco de dados (migration Alembic) seguem o padrão já estabelecido no projeto com reversibilidade garantida (`upgrade`/`downgrade`).
|
||||
973
specs/033-video-apresentacao-imovel/tasks.md
Normal file
973
specs/033-video-apresentacao-imovel/tasks.md
Normal file
|
|
@ -0,0 +1,973 @@
|
|||
# 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.py`
|
||||
|
||||
**Conteúdo completo do arquivo a criar**:
|
||||
|
||||
```python
|
||||
"""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 `Property` — `backend/app/models/property.py`
|
||||
|
||||
**oldString** (após `created_at`, antes de `photos = db.relationship`):
|
||||
```python
|
||||
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
|
||||
|
||||
photos = db.relationship(
|
||||
```
|
||||
|
||||
**newString**:
|
||||
```python
|
||||
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 `PropertyDetailOut` — `backend/app/schemas/property.py`
|
||||
|
||||
**oldString**:
|
||||
```python
|
||||
class PropertyDetailOut(PropertyOut):
|
||||
address: str | None = None
|
||||
code: str | None = None
|
||||
description: str | None = None
|
||||
```
|
||||
|
||||
**newString**:
|
||||
```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'
|
||||
```
|
||||
|
||||
> `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_prop` — `backend/app/routes/admin.py`
|
||||
|
||||
**Passo 4a** — Adicionar `Literal` ao import de typing:
|
||||
|
||||
**oldString**:
|
||||
```python
|
||||
from typing import Optional
|
||||
```
|
||||
|
||||
**newString**:
|
||||
```python
|
||||
from typing import Optional, Literal
|
||||
```
|
||||
|
||||
**Passo 4b** — Adicionar campos ao final do corpo de `PropertyAdminOut` (antes do `@classmethod`):
|
||||
|
||||
**oldString**:
|
||||
```python
|
||||
is_active: bool
|
||||
is_featured: bool
|
||||
photos: list[PhotoAdminOut] = []
|
||||
amenity_ids: list[int] = []
|
||||
|
||||
@classmethod
|
||||
def from_prop(cls, p: Property) -> "PropertyAdminOut":
|
||||
```
|
||||
|
||||
**newString**:
|
||||
```python
|
||||
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**:
|
||||
```python
|
||||
amenity_ids=[a.id for a in p.amenities],
|
||||
)
|
||||
|
||||
|
||||
# ─── Imóveis ─────────────────────────────────────────────────────────────────
|
||||
```
|
||||
|
||||
**newString**:
|
||||
```python
|
||||
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_property` — `backend/app/routes/admin.py`
|
||||
|
||||
**Passo 5a** — Adicionar `video_position` em `_SCALAR_FIELDS`:
|
||||
|
||||
**oldString**:
|
||||
```python
|
||||
"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**:
|
||||
```python
|
||||
"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 `AdminProperty` — `frontend/src/pages/admin/AdminPropertiesPage.tsx`
|
||||
|
||||
**oldString**:
|
||||
```typescript
|
||||
description: string | null
|
||||
is_active: boolean
|
||||
is_featured: boolean
|
||||
photos: AdminPhoto[]
|
||||
amenity_ids: number[]
|
||||
}
|
||||
```
|
||||
|
||||
**newString**:
|
||||
```typescript
|
||||
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 `PropertyFormData` — `frontend/src/pages/admin/PropertyForm.tsx`
|
||||
|
||||
**oldString**:
|
||||
```typescript
|
||||
photos: PhotoItem[];
|
||||
amenity_ids: number[];
|
||||
}
|
||||
```
|
||||
|
||||
**newString**:
|
||||
```typescript
|
||||
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 `handleSubmit` — `frontend/src/pages/admin/PropertyForm.tsx`
|
||||
|
||||
**Passo 8a** — Adicionar estados após `description`:
|
||||
|
||||
**oldString**:
|
||||
```typescript
|
||||
const [description, setDescription] = useState(initial?.description ?? '');
|
||||
const [photos, setPhotos] = useState<PhotoItem[]>(initial?.photos ?? []);
|
||||
```
|
||||
|
||||
**newString**:
|
||||
```typescript
|
||||
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**:
|
||||
```typescript
|
||||
description: description.trim(), photos, amenity_ids: amenityIds,
|
||||
});
|
||||
```
|
||||
|
||||
**newString**:
|
||||
```typescript
|
||||
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**:
|
||||
```tsx
|
||||
{/* ── Fotos ── */}
|
||||
<SectionDivider title="Fotos" />
|
||||
```
|
||||
|
||||
**newString**:
|
||||
```tsx
|
||||
{/* ── 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**:
|
||||
```typescript
|
||||
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**:
|
||||
```typescript
|
||||
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 `getEmbedUrl` — `frontend/src/utils/getEmbedUrl.ts` [NOVO]
|
||||
|
||||
**Conteúdo completo do arquivo a criar**:
|
||||
|
||||
```typescript
|
||||
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 `PropertyDetail` — `frontend/src/types/property.ts`
|
||||
|
||||
**oldString**:
|
||||
```typescript
|
||||
export interface PropertyDetail extends Property {
|
||||
address: string | null
|
||||
code: string | null
|
||||
description: string | null
|
||||
}
|
||||
```
|
||||
|
||||
**newString**:
|
||||
```typescript
|
||||
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.ts` já criado).
|
||||
|
||||
**Conteúdo completo do arquivo a criar**:
|
||||
|
||||
```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 (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**:
|
||||
```typescript
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { PropertyPhoto } from '../../types/property'
|
||||
|
||||
interface PhotoCarouselProps {
|
||||
photos: PropertyPhoto[]
|
||||
}
|
||||
```
|
||||
|
||||
**newString**:
|
||||
```typescript
|
||||
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**:
|
||||
```typescript
|
||||
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**:
|
||||
```typescript
|
||||
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**:
|
||||
```typescript
|
||||
if (photos.length === 0) return <NoPhotoPlaceholder />
|
||||
|
||||
const active = photos[activeIndex]
|
||||
const single = photos.length === 1
|
||||
```
|
||||
|
||||
**newString**:
|
||||
```typescript
|
||||
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**:
|
||||
```tsx
|
||||
{/* 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**:
|
||||
```tsx
|
||||
{/* 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**:
|
||||
```tsx
|
||||
{/* 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**:
|
||||
```tsx
|
||||
{/* 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**:
|
||||
```tsx
|
||||
{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**:
|
||||
```tsx
|
||||
{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**:
|
||||
```typescript
|
||||
import PhotoCarousel from '../components/PropertyDetail/PhotoCarousel';
|
||||
```
|
||||
|
||||
**newString**:
|
||||
```typescript
|
||||
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**:
|
||||
```tsx
|
||||
{/* Carousel */}
|
||||
<PhotoCarousel photos={property.photos} />
|
||||
|
||||
{/* Stats */}
|
||||
```
|
||||
|
||||
**newString**:
|
||||
```tsx
|
||||
{/* 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**:
|
||||
```typescript
|
||||
import api from '../../services/api';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
```
|
||||
|
||||
**newString**:
|
||||
```typescript
|
||||
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**:
|
||||
```tsx
|
||||
<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**:
|
||||
```tsx
|
||||
{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/`
|
||||
|
||||
```bash
|
||||
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 }`
|
||||
Loading…
Add table
Add a link
Reference in a new issue