feat(frontend): video presentation on property detail page

- VideoPlayer: auto-detects YouTube/Vimeo embed or direct video file
- PhotoCarousel: accepts videoUrl prop, shows video as first slide with play icon thumbnail
- PropertyDetailPage: renders video in carousel (position=carousel) or as standalone section (position=section)
- PriceBox: fix sticky offset for navbar height
- PropertyForm: video URL input with live preview + position selector
- AdminPropertiesPage: include video fields in edit initial data
- types/property: add video_url and video_position to PropertyDetail
- utils/getEmbedUrl: helper to resolve YouTube/Vimeo/direct URLs
This commit is contained in:
MatheusAlves96 2026-04-22 23:57:45 -03:00
parent d363a09f36
commit 2e9f903d06
8 changed files with 252 additions and 35 deletions

View file

@ -1,8 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { PropertyPhoto } from '../../types/property'
import VideoPlayer from './VideoPlayer'
interface PhotoCarouselProps {
photos: PropertyPhoto[]
videoUrl?: string | null
}
function ChevronLeft() {
@ -34,18 +36,20 @@ function NoPhotoPlaceholder() {
)
}
export default function PhotoCarousel({ photos }: PhotoCarouselProps) {
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 ? photos.length - 1 : i - 1))
}, [photos.length])
setActiveIndex((i) => (i === 0 ? totalSlides - 1 : i - 1))
}, [totalSlides])
const next = useCallback(() => {
setActiveIndex((i) => (i === photos.length - 1 ? 0 : i + 1))
}, [photos.length])
setActiveIndex((i) => (i === totalSlides - 1 ? 0 : i + 1))
}, [totalSlides])
useEffect(() => {
const el = containerRef.current
@ -58,21 +62,29 @@ export default function PhotoCarousel({ photos }: PhotoCarouselProps) {
return () => el.removeEventListener('keydown', handleKey)
}, [prev, next])
if (photos.length === 0) return <NoPhotoPlaceholder />
if (totalSlides === 0) return <NoPhotoPlaceholder />
const active = photos[activeIndex]
const single = photos.length === 1
const isVideoSlide = hasVideo && activeIndex === 0
const photoIndex = hasVideo ? activeIndex - 1 : activeIndex
const active = !isVideoSlide ? photos[photoIndex] : null
const single = totalSlides === 1
return (
<div ref={containerRef} className="w-full outline-none" tabIndex={0}>
{/* 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"
/>
{/* 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>
)}
{/* Nav buttons */}
{!single && (
@ -94,7 +106,7 @@ export default function PhotoCarousel({ photos }: PhotoCarouselProps) {
{/* 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}
{activeIndex + 1} / {totalSlides}
</div>
</>
)}
@ -113,25 +125,53 @@ export default function PhotoCarousel({ photos }: PhotoCarouselProps) {
touchStartX.current = null
}}
>
{photos.map((photo, idx) => (
{hasVideo && (
<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'
}`}
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'
}`}
>
<img
src={photo.url}
alt={photo.alt_text || `Miniatura ${idx + 1}`}
className="w-full h-full object-cover"
loading="lazy"
/>
<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>
)
})}
</div>
)}
</div>

View file

@ -17,7 +17,7 @@ export default function PriceBox({ price, condo_fee, listing_type }: PriceBoxPro
const isVenda = listing_type === 'venda'
return (
<div className="bg-panel border border-white/5 rounded-xl p-5 lg:sticky lg:top-6">
<div className="bg-panel border border-white/5 rounded-xl p-5 lg:sticky lg:top-[calc(3.5rem+1.5rem)]">
{/* Badge */}
<span
className={`inline-flex items-center rounded-full text-xs font-medium px-2.5 py-1 mb-4 ${isVenda

View file

@ -0,0 +1,40 @@
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
}

View file

@ -7,6 +7,7 @@ import Navbar from '../components/Navbar';
import AmenitiesSection from '../components/PropertyDetail/AmenitiesSection';
import ContactSection from '../components/PropertyDetail/ContactSection';
import PhotoCarousel from '../components/PropertyDetail/PhotoCarousel';
import VideoPlayer from '../components/PropertyDetail/VideoPlayer';
import PriceBox from '../components/PropertyDetail/PriceBox';
import PropertyDetailSkeleton from '../components/PropertyDetail/PropertyDetailSkeleton';
import StatsStrip from '../components/PropertyDetail/StatsStrip';
@ -146,7 +147,24 @@ export default function PropertyDetailPage() {
{/* Left — main content */}
<div className="flex-1 min-w-0 space-y-8">
{/* Carousel */}
<PhotoCarousel photos={property.photos} />
<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 */}
<StatsStrip

View file

@ -38,6 +38,8 @@ interface AdminProperty {
is_featured: boolean
photos: AdminPhoto[]
amenity_ids: number[]
video_url: string | null
video_position: 'carousel' | 'section'
}
interface PaginatedResponse {
@ -564,6 +566,8 @@ export default function AdminPropertiesPage() {
code: editing.code ?? '',
description: editing.description ?? '',
amenity_ids: editing.amenity_ids ?? [],
video_url: editing.video_url ?? '',
video_position: editing.video_position ?? 'section',
} : undefined}
onSubmit={editing ? handleEdit : handleCreate}
onCancel={() => { setShowForm(false); setEditing(null) }}

View file

@ -1,5 +1,6 @@
import api from '../../services/api';
import { useEffect, useRef, useState, useCallback } from 'react';
import { getEmbedUrl } from '../../utils/getEmbedUrl';
// ─── Tipos exportados ────────────────────────────────────────────────────────
@ -29,6 +30,8 @@ export interface PropertyFormData {
description: string;
photos: PhotoItem[];
amenity_ids: number[];
video_url: string;
video_position: 'carousel' | 'section';
}
// ─── Tipos internos ──────────────────────────────────────────────────────────
@ -290,6 +293,10 @@ export default function PropertyForm({ initial, onSubmit, onCancel, isLoading }:
const [parkingCovered, setParkingCovered] = useState(initial?.parking_spots_covered ?? 0);
const [areaM2, setAreaM2] = useState(initial?.area_m2 ?? 0);
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 ?? []);
const [amenityIds, setAmenityIds] = useState<number[]>(initial?.amenity_ids ?? []);
@ -433,6 +440,7 @@ export default function PropertyForm({ initial, onSubmit, onCancel, isLoading }:
bedrooms, bathrooms, parking_spots: parkingSpots,
parking_spots_covered: parkingCovered, area_m2: areaM2,
description: description.trim(), photos, amenity_ids: amenityIds,
video_url: videoUrl.trim(), video_position: videoPosition,
});
}
@ -630,6 +638,73 @@ export default function PropertyForm({ initial, onSubmit, onCancel, isLoading }:
placeholder="Descreva os detalhes, diferenciais e características do imóvel…"
className={`${inputCls()} resize-none`} />
{/* ── 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>
{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>
</div>
{/* ── Fotos ── */}
<SectionDivider title="Fotos" />
<PhotoUploader

View file

@ -43,6 +43,8 @@ export interface PropertyDetail extends Property {
address: string | null
code: string | null
description: string | null
video_url: string | null
video_position: 'carousel' | 'section'
}
export interface ContactFormData {

View file

@ -0,0 +1,38 @@
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 }
}