diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 0dd2244..ba80170 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -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)
diff --git a/.specify/feature.json b/.specify/feature.json
index 35b474e..eb894da 100644
--- a/.specify/feature.json
+++ b/.specify/feature.json
@@ -1,3 +1,3 @@
{
- "feature_directory": "specs/029-ux-area-do-cliente"
+ "feature_directory": "specs/033-video-apresentacao-imovel"
}
diff --git a/backend/seeds/seed.py b/backend/seeds/seed.py
index 54e57b5..ffaf551 100644
--- a/backend/seeds/seed.py
+++ b/backend/seeds/seed.py
@@ -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",
diff --git a/specs/033-video-apresentacao-imovel/checklists/requirements.md b/specs/033-video-apresentacao-imovel/checklists/requirements.md
new file mode 100644
index 0000000..b7beb29
--- /dev/null
+++ b/specs/033-video-apresentacao-imovel/checklists/requirements.md
@@ -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
diff --git a/specs/033-video-apresentacao-imovel/contracts/api.md b/specs/033-video-apresentacao-imovel/contracts/api.md
new file mode 100644
index 0000000..51527b3
--- /dev/null
+++ b/specs/033-video-apresentacao-imovel/contracts/api.md
@@ -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` | `` dentro de wrapper 16:9 |
+| `direct` | `` 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 ``
+- 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)
diff --git a/specs/033-video-apresentacao-imovel/data-model.md b/specs/033-video-apresentacao-imovel/data-model.md
new file mode 100644
index 0000000..9732ed0
--- /dev/null
+++ b/specs/033-video-apresentacao-imovel/data-model.md
@@ -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'
+```
diff --git a/specs/033-video-apresentacao-imovel/plan.md b/specs/033-video-apresentacao-imovel/plan.md
new file mode 100644
index 0000000..b4f5885
--- /dev/null
+++ b/specs/033-video-apresentacao-imovel/plan.md
@@ -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 (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
+```
+
+---
+
+### 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` → ``; caso contrário → `` 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) */}
+
+
+{/* Seção de vídeo (posição section) */}
+{property.video_url && property.video_position === 'section' && (
+
+
+ Vídeo de Apresentação
+
+
+
+)}
+```
+
+> 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**: `` — label "URL do vídeo", placeholder `https://youtube.com/watch?v=...`
+2. **Select posição**: `