import React, { useState, useEffect, useRef } from "react"; import { Product, OrderItem } from "../types"; import { productService, SaleProduct } from "../src/services/product.service"; import { authService } from "../src/services/auth.service"; import NoImagePlaceholder from "./NoImagePlaceholder"; import RelatedProductCard from "./RelatedProductCard"; import FilialSelector from "./FilialSelector"; import { X, ChevronRight, ChevronLeft, Minus, Plus, ShoppingCart, FileText, Info, ArrowRight, AlertCircle, } from "lucide-react"; import { validateMinValue, validateRequired } from "../lib/utils"; interface ProductDetailModalProps { product: Product | null; isOpen: boolean; onClose: () => void; onAddToCart: (p: Product | OrderItem) => void; initialQuantity?: number; // Quantidade inicial ao abrir o modal onProductChange?: (product: Product) => void; // Callback para mudar o produto exibido no modal } interface ProductDetail extends SaleProduct { images?: string[]; stocks?: Array<{ store: string; storeName: string; quantity: number; work: boolean; blocked: string; breakdown: number; transfer: number; allowDelivery: number; }>; installments?: Array<{ installment: number; installmentValue: number; }>; } const ProductDetailModal: React.FC = ({ product, isOpen, onClose, onAddToCart, initialQuantity = 1, onProductChange, }) => { const [productDetail, setProductDetail] = useState( null ); const [buyTogether, setBuyTogether] = useState([]); const [similarProducts, setSimilarProducts] = useState([]); const [loading, setLoading] = useState(false); const [loadingRelated, setLoadingRelated] = useState(false); const [error, setError] = useState(null); const [quantity, setQuantity] = useState(initialQuantity); const [selectedStore, setSelectedStore] = useState(""); const [deliveryType, setDeliveryType] = useState(""); const [currentImageIndex, setCurrentImageIndex] = useState(0); const [activeTab, setActiveTab] = useState<"details" | "specs">("details"); const [imageError, setImageError] = useState(false); // Estados para validação de formulário const [formErrors, setFormErrors] = useState<{ quantity?: string; deliveryType?: string; selectedStore?: string; }>({}); // Refs para scroll até as seções (scroll vertical) const buyTogetherSectionRef = useRef(null); const similarProductsSectionRef = useRef(null); // Ref para o container principal do modal (para fazer scroll) const modalContentRef = useRef(null); // Ref para controlar animação em andamento const scrollAnimationRef = useRef(null); // Refs para containers de rolagem horizontal (Compre Junto e Produtos Similares) const buyTogetherScrollRef = useRef(null); const similarProductsScrollRef = useRef(null); // Estados para controlar visibilidade dos botões de rolagem const [buyTogetherScrollState, setBuyTogetherScrollState] = useState({ canScrollLeft: false, canScrollRight: false, }); const [similarProductsScrollState, setSimilarProductsScrollState] = useState({ canScrollLeft: false, canScrollRight: false, }); // Tipos de entrega - seguindo EXATAMENTE o padrão do Angular // Fonte: vendaweb_portal/src/app/sales/components/tintometrico-modal/tintometrico-modal.component.ts const deliveryTypes = [ { type: "RI", description: "Retira Imediata" }, { type: "RP", description: "Retira Posterior" }, { type: "EN", description: "Entrega" }, { type: "EF", description: "Encomenda" }, ]; useEffect(() => { if (isOpen && product) { // Inicializar quantidade com o valor inicial ou 1 setQuantity(initialQuantity || 1); loadProductDetail(); loadRelatedProducts(); } else { // Reset states when modal closes setProductDetail(null); setBuyTogether([]); setSimilarProducts([]); setQuantity(1); setCurrentImageIndex(0); setActiveTab("details"); } }, [isOpen, product, initialQuantity]); // Função para scroll suave e elegante até uma seção específica // Usa animação customizada com easing para uma experiência mais fluida const scrollToSection = (sectionRef: React.RefObject) => { if (!sectionRef.current || !modalContentRef.current) return; // Cancelar animação anterior se houver para evitar conflitos if (scrollAnimationRef.current !== null) { cancelAnimationFrame(scrollAnimationRef.current); scrollAnimationRef.current = null; } const sectionElement = sectionRef.current; const containerElement = modalContentRef.current; // Calcular a posição relativa da seção dentro do container const containerRect = containerElement.getBoundingClientRect(); const sectionRect = sectionElement.getBoundingClientRect(); // Calcular o offset necessário (posição da seção - posição do container + scroll atual) const targetScroll = sectionRect.top - containerRect.top + containerElement.scrollTop - 24; // 24px de margem elegante const startScroll = containerElement.scrollTop; const distance = targetScroll - startScroll; // Se a distância for muito pequena, não animar if (Math.abs(distance) < 5) { containerElement.scrollTop = targetScroll; return; } // Duração adaptativa: mais suave para distâncias maiores // Base: 600ms, máximo: 1200ms, proporcional à distância const baseDuration = 600; const maxDuration = 1200; const distanceFactor = Math.min(Math.abs(distance) / 600, 1); const duration = baseDuration + (maxDuration - baseDuration) * distanceFactor; let startTime: number | null = null; // Função de easing (ease-out-quart) para animação mais elegante e natural // Esta função cria uma curva de aceleração suave que desacelera suavemente no final // Mais suave que cubic, proporciona uma sensação mais premium e fluida const easeOutQuart = (t: number): number => { return 1 - Math.pow(1 - t, 4); }; // Função de animação usando requestAnimationFrame para máxima fluidez (60fps) const animateScroll = (currentTime: number) => { if (startTime === null) startTime = currentTime; const timeElapsed = currentTime - startTime; const progress = Math.min(timeElapsed / duration, 1); // Aplicar easing para movimento suave e natural const easedProgress = easeOutQuart(progress); const currentScroll = startScroll + distance * easedProgress; containerElement.scrollTop = currentScroll; // Continuar animação até completar if (progress < 1) { scrollAnimationRef.current = requestAnimationFrame(animateScroll); } else { // Garantir que chegamos exatamente na posição final containerElement.scrollTop = targetScroll; scrollAnimationRef.current = null; } }; // Iniciar animação scrollAnimationRef.current = requestAnimationFrame(animateScroll); }; // Função para verificar estado de rolagem horizontal const checkScrollState = ( containerRef: React.RefObject, setState: React.Dispatch< React.SetStateAction<{ canScrollLeft: boolean; canScrollRight: boolean }> > ) => { if (!containerRef.current) return; const container = containerRef.current; const { scrollLeft, scrollWidth, clientWidth } = container; // Verificar se há conteúdo suficiente para rolar const hasScrollableContent = scrollWidth > clientWidth; const isAtStart = scrollLeft <= 5; // 5px de tolerância const isAtEnd = scrollLeft >= scrollWidth - clientWidth - 5; // 5px de tolerância setState({ canScrollLeft: hasScrollableContent && !isAtStart, canScrollRight: hasScrollableContent && !isAtEnd, }); }; // Função para rolar horizontalmente const scrollHorizontally = ( containerRef: React.RefObject, direction: "left" | "right", setState: React.Dispatch< React.SetStateAction<{ canScrollLeft: boolean; canScrollRight: boolean }> > ) => { if (!containerRef.current) return; const container = containerRef.current; const scrollAmount = 300; // Quantidade de pixels para rolar const targetScroll = direction === "left" ? container.scrollLeft - scrollAmount : container.scrollLeft + scrollAmount; container.scrollTo({ left: targetScroll, behavior: "smooth", }); // Verificar estado após um pequeno delay para permitir a animação setTimeout(() => { checkScrollState(containerRef, setState); }, 100); }; const loadProductDetail = async () => { if (!product?.code) return; try { setLoading(true); setError(null); const store = authService.getStore(); if (!store) { throw new Error("Loja não encontrada. Faça login novamente."); } const productId = parseInt(product.code); if (isNaN(productId)) { throw new Error("ID do produto inválido"); } // Buscar detalhes do produto const detail = await productService.getProductDetail(store, productId); // Processar imagens const images: string[] = []; if (detail.urlImage) { // Se urlImage contém múltiplas URLs separadas por vírgula, ponto e vírgula ou pipe const imageUrls = detail.urlImage .split(/[,;|]/) .map((url) => url.trim()) .filter((url) => url.length > 0); if (imageUrls.length > 0) { images.push(...imageUrls); } else { // Se não conseguiu separar, usar a URL completa images.push(detail.urlImage); } } if (images.length === 0 && product.image) { images.push(product.image); } setProductDetail({ ...detail, images }); // Buscar estoques try { const stocks = await productService.getProductStocks(store, productId); setProductDetail((prev) => (prev ? { ...prev, stocks } : null)); } catch (err) { console.warn("Erro ao carregar estoques:", err); } // Buscar parcelamento try { const installments = await productService.getProductInstallments( store, productId, quantity ); setProductDetail((prev) => (prev ? { ...prev, installments } : null)); } catch (err) { console.warn("Erro ao carregar parcelamento:", err); } } catch (err: any) { console.error("Erro ao carregar detalhes do produto:", err); setError(err.message || "Erro ao carregar detalhes do produto"); } finally { setLoading(false); } }; const loadRelatedProducts = async () => { if (!product?.code) return; try { setLoadingRelated(true); const store = authService.getStore(); if (!store) return; const productId = parseInt(product.code); if (isNaN(productId)) return; // Buscar produtos "compre junto" e similares em paralelo const [buyTogetherData, similarData] = await Promise.all([ productService.getProductsBuyTogether(store, productId).catch(() => []), productService.getProductsSimilar(store, productId).catch(() => []), ]); setBuyTogether(buyTogetherData || []); setSimilarProducts(similarData || []); } catch (err: any) { console.error("Erro ao carregar produtos relacionados:", err); } finally { setLoadingRelated(false); } }; // Verificar estado de rolagem quando os produtos relacionados são carregados useEffect(() => { if (buyTogether.length > 0) { // Usar requestAnimationFrame para garantir que o DOM foi renderizado const checkAfterRender = () => { requestAnimationFrame(() => { checkScrollState(buyTogetherScrollRef, setBuyTogetherScrollState); // Verificar novamente após um pequeno delay para garantir setTimeout(() => { checkScrollState(buyTogetherScrollRef, setBuyTogetherScrollState); }, 100); }); }; // Verificar imediatamente e após delays checkAfterRender(); const timeout1 = setTimeout(checkAfterRender, 200); const timeout2 = setTimeout(checkAfterRender, 500); return () => { clearTimeout(timeout1); clearTimeout(timeout2); }; } }, [buyTogether, isOpen, loadingRelated]); useEffect(() => { if (similarProducts.length > 0) { // Usar requestAnimationFrame para garantir que o DOM foi renderizado const checkAfterRender = () => { requestAnimationFrame(() => { checkScrollState( similarProductsScrollRef, setSimilarProductsScrollState ); // Verificar novamente após um pequeno delay para garantir setTimeout(() => { checkScrollState( similarProductsScrollRef, setSimilarProductsScrollState ); }, 100); }); }; // Verificar imediatamente e após delays checkAfterRender(); const timeout1 = setTimeout(checkAfterRender, 200); const timeout2 = setTimeout(checkAfterRender, 500); return () => { clearTimeout(timeout1); clearTimeout(timeout2); }; } }, [similarProducts, isOpen, loadingRelated]); // Adicionar listener para scroll manual (mouse/touch) e resize useEffect(() => { const buyTogetherContainer = buyTogetherScrollRef.current; const similarProductsContainer = similarProductsScrollRef.current; const handleBuyTogetherScroll = () => { checkScrollState(buyTogetherScrollRef, setBuyTogetherScrollState); }; const handleSimilarProductsScroll = () => { checkScrollState(similarProductsScrollRef, setSimilarProductsScrollState); }; // Função para verificar após resize da janela const handleResize = () => { if (buyTogetherContainer) { checkScrollState(buyTogetherScrollRef, setBuyTogetherScrollState); } if (similarProductsContainer) { checkScrollState( similarProductsScrollRef, setSimilarProductsScrollState ); } }; if (buyTogetherContainer) { buyTogetherContainer.addEventListener("scroll", handleBuyTogetherScroll); } if (similarProductsContainer) { similarProductsContainer.addEventListener( "scroll", handleSimilarProductsScroll ); } // Adicionar listener de resize window.addEventListener("resize", handleResize); return () => { if (buyTogetherContainer) { buyTogetherContainer.removeEventListener( "scroll", handleBuyTogetherScroll ); } if (similarProductsContainer) { similarProductsContainer.removeEventListener( "scroll", handleSimilarProductsScroll ); } window.removeEventListener("resize", handleResize); }; }, [buyTogether.length, similarProducts.length, isOpen]); // Função para validar o formulário const validateForm = (): { isValid: boolean; errors: typeof formErrors } => { const errors: { quantity?: string; deliveryType?: string; selectedStore?: string; } = {}; // Validar quantidade if (!validateRequired(quantity)) { errors.quantity = "A quantidade é obrigatória"; } else if (!validateMinValue(quantity, 0.01)) { errors.quantity = "A quantidade deve ser maior que zero"; } else if (isNaN(quantity) || quantity <= 0) { errors.quantity = "A quantidade deve ser um número válido maior que zero"; } // Validar tipo de entrega if (!validateRequired(deliveryType)) { errors.deliveryType = "O tipo de entrega é obrigatório"; } // Validar filial de estoque (quando há estoques disponíveis) if ( productDetail?.stocks && productDetail.stocks.length > 0 && !validateRequired(selectedStore) ) { errors.selectedStore = "A filial de estoque é obrigatória quando há estoques disponíveis"; } setFormErrors(errors); // Retorna objeto com isValid e errors return { isValid: Object.keys(errors).length === 0, errors }; }; const handleAddToCart = () => { if (!product) { setError("Produto não encontrado"); return; } // Limpar erros anteriores setFormErrors({}); setError(null); // Validar formulário const validation = validateForm(); if (!validation.isValid) { // Aguardar um tick para que o estado seja atualizado antes de focar setTimeout(() => { const firstErrorField = Object.keys(validation.errors)[0]; if (firstErrorField === "quantity") { // Focar no input de quantidade const quantityInput = document.querySelector( 'input[type="number"]' ) as HTMLInputElement; quantityInput?.focus(); } else if (firstErrorField === "deliveryType") { // Focar no select de tipo de entrega const deliverySelect = document.querySelector( "select" ) as HTMLSelectElement; deliverySelect?.focus(); } else if (firstErrorField === "selectedStore") { // Focar no FilialSelector const storeInput = document.querySelector( 'input[placeholder*="buscar"]' ) as HTMLInputElement; storeInput?.focus(); } }, 100); return; } // Criar OrderItem com todos os campos necessários // Seguindo exatamente o padrão do Angular (product-detail.component.ts linha 173-203) // Se temos productDetail (SaleProduct), usar seus dados diretamente const productWithQuantity: OrderItem = { ...product, // Se productDetail existe, usar os dados do SaleProduct (mais completos) ...(productDetail && { id: productDetail.idProduct?.toString() || product.id, code: productDetail.idProduct?.toString() || product.code, name: productDetail.title || product.name, description: productDetail.description || productDetail.smallDescription || product.description, price: productDetail.salePrice || product.price, originalPrice: productDetail.listPrice || product.originalPrice, discount: productDetail.offPercent || product.discount, mark: productDetail.brand || product.mark, image: productDetail.images?.[0] || productDetail.urlImage || product.image, ean: productDetail.ean || product.ean, model: productDetail.idProduct?.toString() || product.model, productType: productDetail.productType, mutiple: productDetail.mutiple, base: productDetail.base, letter: productDetail.letter, line: productDetail.line, color: productDetail.color, can: productDetail.can, }), quantity, deliveryType, // Tipo de entrega selecionado (obrigatório) stockStore: selectedStore || product.stockLocal?.toString() || null, // Filial selecionada ou estoque local }; onAddToCart(productWithQuantity); // Opcional: fechar modal após adicionar // onClose(); }; const mapSaleProductToProduct = (saleProduct: SaleProduct): Product => { const productId = saleProduct.idProduct?.toString() || saleProduct.id?.toString() || ""; let discount: number | undefined = saleProduct.offPercent; const listPrice = saleProduct.listPrice || 0; const salePrice = saleProduct.salePrice || saleProduct.salePromotion || 0; if (!discount && listPrice > 0 && salePrice > 0 && salePrice < listPrice) { discount = Math.round(((listPrice - salePrice) / listPrice) * 100); } return { id: productId || `product-${Math.random()}`, code: productId || "", name: saleProduct.title || saleProduct.smallDescription || saleProduct.description || "Produto sem nome", description: saleProduct.description || saleProduct.smallDescription, price: salePrice || listPrice || 0, originalPrice: listPrice > 0 && salePrice > 0 && salePrice < listPrice ? listPrice : undefined, discount: discount, mark: saleProduct.brand || "Sem marca", image: saleProduct.urlImage || "", stockLocal: saleProduct.store_stock || saleProduct.stock || 0, stockAvailable: saleProduct.stock || saleProduct.store_stock || 0, stockGeneral: saleProduct.full_stock || saleProduct.stock || 0, ean: saleProduct.ean || undefined, model: saleProduct.idProduct?.toString() || undefined, }; }; if (!isOpen || !product) return null; const images = productDetail?.images || (product.image ? [product.image] : []); const hasMultipleImages = images.length > 1; const hasValidImage = images.length > 0 && currentImageIndex >= 0 && currentImageIndex < images.length; return (
{/* Header */}

Detalhes do Produto

{/* Botões de navegação rápida */} {!loading && (buyTogether.length > 0 || similarProducts.length > 0) && (
{buyTogether.length > 0 && ( )} {similarProducts.length > 0 && ( )}
)}
{/* Content */}
{loading ? (

Carregando detalhes...

) : error ? (

{error}

) : (
{/* Left Column - Images */}
{/* Main Image */}
{hasValidImage && images[currentImageIndex] ? ( <> {product.name} { // Se a imagem falhar ao carregar, mostrar placeholder setCurrentImageIndex(-1); }} /> {/* Navigation Arrows */} {hasMultipleImages && ( <> )} ) : ( )}
{/* Thumbnails */} {hasMultipleImages && (
{images.map((img, idx) => ( ))}
)}
{/* Right Column - Product Info */}
{/* Title and Badges */}

{productDetail?.title || product.name}

Código: {productDetail?.idProduct || product.code} {productDetail?.productType === "A" && ( Autosserviço )} {productDetail?.productType === "S" && ( Showroom )} {product.discount ? ( {product.discount.toFixed(0)}% OFF ) : null}
{/* Description */}

Descrição

{productDetail?.description || product.description || "Sem descrição disponível"}

{/* Price */}
{product.originalPrice && product.originalPrice > product.price && (

de R${" "} {product.originalPrice.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2, })}

)}

por R${" "} {product.price.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2, })}

{productDetail?.installments && productDetail.installments.length > 0 && (

ou em {productDetail.installments[0].installment}x de R${" "} {productDetail.installments[0].installmentValue.toLocaleString( "pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2, } )}

)}
{/* Add to Cart Form */}

Adicionar ao Carrinho

{/* Quantity */}
{ const val = parseFloat(e.target.value); if (!isNaN(val) && val > 0) { setQuantity(val); // Limpar erro ao alterar if (formErrors.quantity) { setFormErrors((prev) => ({ ...prev, quantity: undefined, })); } } }} onBlur={() => { // Validar ao sair do campo if ( !validateRequired(quantity) || !validateMinValue(quantity, 0.01) ) { setFormErrors((prev) => ({ ...prev, quantity: "A quantidade deve ser maior que zero", })); } }} className={`w-20 text-center text-sm font-bold border-0 focus:outline-none focus:ring-2 focus:ring-orange-500/20 bg-white ${ formErrors.quantity ? "text-red-600" : "" }`} />
{formErrors.quantity && (
{formErrors.quantity}
)}
{/* Stock Info */} {productDetail?.stocks && productDetail.stocks.length > 0 && (
{ setSelectedStore(value); // Limpar erro ao alterar if (formErrors.selectedStore) { setFormErrors((prev) => ({ ...prev, selectedStore: undefined, })); } }} label="Filial Retira" placeholder="Digite para buscar..." /> {formErrors.selectedStore && (
{formErrors.selectedStore}
)}
)} {/* Delivery Type */}
{formErrors.deliveryType && (
{formErrors.deliveryType}
)}
{/* Add Button */}
{/* Tabs */}
{activeTab === "details" && (
EAN

{product.ean || "N/A"}

Marca

{product.mark || "N/A"}

Estoque Loja

{product.stockLocal || 0} UN

Estoque Disponível

{product.stockAvailable || 0} UN

)} {activeTab === "specs" && (

{productDetail?.technicalData || "Sem especificações técnicas disponíveis"}

)}
)} {/* Compre Junto Section */} {!loading && buyTogether.length > 0 && (

Compre Junto

{loadingRelated ? (
) : (
{/* Botão de rolagem esquerda - sempre visível */} {/* Container com produtos */}
{buyTogether.map((relatedProduct) => { const mappedProduct = mapSaleProductToProduct(relatedProduct); return (
onAddToCart({ ...p, quantity: 1 }) } onClick={() => { // Atualizar o produto exibido no modal if (onProductChange) { onProductChange(mappedProduct); // Scroll para o topo do modal if (modalContentRef.current) { modalContentRef.current.scrollTo({ top: 0, behavior: "smooth", }); } } }} />
); })}
{/* Botão de rolagem direita - sempre visível quando há scroll */}
)}
)} {/* Produtos Similares */} {!loading && similarProducts.length > 0 && (

Produtos Similares

{loadingRelated ? (
) : (
{/* Botão de rolagem esquerda - sempre visível quando há scroll */} {/* Container com produtos */}
{similarProducts.map((similarProduct) => { const mappedProduct = mapSaleProductToProduct(similarProduct); return (
onAddToCart({ ...p, quantity: 1 }) } onClick={() => { // Atualizar o produto exibido no modal if (onProductChange) { onProductChange(mappedProduct); // Scroll para o topo do modal if (modalContentRef.current) { modalContentRef.current.scrollTo({ top: 0, behavior: "smooth", }); } } }} />
); })}
{/* Botão de rolagem direita - sempre visível quando há scroll */}
)}
)}
); }; export default ProductDetailModal;