Vendaweb-portal/components/ProductDetailModal.tsx

1324 lines
52 KiB
TypeScript
Raw Permalink Normal View History

2026-01-08 12:09:16 +00:00
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<ProductDetailModalProps> = ({
product,
isOpen,
onClose,
onAddToCart,
initialQuantity = 1,
onProductChange,
}) => {
const [productDetail, setProductDetail] = useState<ProductDetail | null>(
null
);
const [buyTogether, setBuyTogether] = useState<SaleProduct[]>([]);
const [similarProducts, setSimilarProducts] = useState<SaleProduct[]>([]);
const [loading, setLoading] = useState(false);
const [loadingRelated, setLoadingRelated] = useState(false);
const [error, setError] = useState<string | null>(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<HTMLDivElement>(null);
const similarProductsSectionRef = useRef<HTMLDivElement>(null);
// Ref para o container principal do modal (para fazer scroll)
const modalContentRef = useRef<HTMLDivElement>(null);
// Ref para controlar animação em andamento
const scrollAnimationRef = useRef<number | null>(null);
// Refs para containers de rolagem horizontal (Compre Junto e Produtos Similares)
const buyTogetherScrollRef = useRef<HTMLDivElement>(null);
const similarProductsScrollRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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<HTMLDivElement>,
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<HTMLDivElement>,
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-white rounded-3xl shadow-2xl max-w-7xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-200">
<div className="flex items-center gap-4">
<h2 className="text-2xl font-black text-[#002147]">
Detalhes do Produto
</h2>
{/* Botões de navegação rápida */}
{!loading &&
(buyTogether.length > 0 || similarProducts.length > 0) && (
<div className="flex items-center gap-2 ml-4 pl-4 border-l border-slate-200">
{buyTogether.length > 0 && (
<button
onClick={() => scrollToSection(buyTogetherSectionRef)}
className="px-4 py-2 bg-orange-50 hover:bg-orange-100 text-orange-600 rounded-lg font-bold text-xs uppercase tracking-wider transition-all duration-300 hover:scale-105 border border-orange-200"
title="Ir para Compre Junto"
>
<div className="flex items-center gap-2">
<ArrowRight className="w-4 h-4" />
<span>Compre Junto</span>
</div>
</button>
)}
{similarProducts.length > 0 && (
<button
onClick={() => scrollToSection(similarProductsSectionRef)}
className="px-4 py-2 bg-blue-50 hover:bg-blue-100 text-blue-600 rounded-lg font-bold text-xs uppercase tracking-wider transition-all duration-300 hover:scale-105 border border-blue-200"
title="Ir para Produtos Similares"
>
<div className="flex items-center gap-2">
<ArrowRight className="w-4 h-4" />
<span>Similares</span>
</div>
</button>
)}
</div>
)}
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-xl transition-colors"
>
<X className="w-6 h-6 text-slate-600" />
</button>
</div>
{/* Content */}
<div
ref={modalContentRef}
className="flex-1 overflow-y-auto custom-scrollbar p-6"
>
{loading ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-slate-200 border-t-orange-500 mb-4"></div>
<p className="text-slate-600 font-bold text-sm">
Carregando detalhes...
</p>
</div>
) : error ? (
<div className="p-4 bg-red-50 border border-red-200 rounded-2xl">
<p className="text-red-600 text-sm font-medium">{error}</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Left Column - Images */}
<div className="space-y-4">
{/* Main Image */}
<div className="bg-slate-50 rounded-2xl p-8 flex items-center justify-center min-h-[400px] relative overflow-hidden">
{hasValidImage && images[currentImageIndex] ? (
<>
<img
src={images[currentImageIndex]}
alt={product.name}
className="max-h-full max-w-full object-contain mix-blend-multiply"
onError={() => {
// Se a imagem falhar ao carregar, mostrar placeholder
setCurrentImageIndex(-1);
}}
/>
{/* Navigation Arrows */}
{hasMultipleImages && (
<>
<button
onClick={() =>
setCurrentImageIndex((prev) =>
prev > 0 ? prev - 1 : images.length - 1
)
}
className="absolute left-4 top-1/2 -translate-y-1/2 bg-white/80 hover:bg-white p-3 rounded-full shadow-lg transition-all"
>
<ChevronLeft className="w-6 h-6 text-[#002147]" />
</button>
<button
onClick={() =>
setCurrentImageIndex((prev) =>
prev < images.length - 1 ? prev + 1 : 0
)
}
className="absolute right-4 top-1/2 -translate-y-1/2 bg-white/80 hover:bg-white p-3 rounded-full shadow-lg transition-all"
>
<ChevronRight className="w-6 h-6 text-[#002147]" />
</button>
</>
)}
</>
) : (
<NoImagePlaceholder size="lg" />
)}
</div>
{/* Thumbnails */}
{hasMultipleImages && (
<div className="flex gap-2 overflow-x-auto custom-scrollbar pb-2">
{images.map((img, idx) => (
<button
key={idx}
onClick={() => setCurrentImageIndex(idx)}
className={`flex-shrink-0 w-20 h-20 rounded-xl overflow-hidden border-2 transition-all ${
currentImageIndex === idx
? "border-orange-500"
: "border-slate-200 hover:border-slate-300"
}`}
>
<img
src={img}
alt={`Imagem ${idx + 1}`}
className="w-full h-full object-cover"
/>
</button>
))}
</div>
)}
</div>
{/* Right Column - Product Info */}
<div className="space-y-6">
{/* Title and Badges */}
<div>
<h3 className="text-3xl font-black text-[#002147] mb-4 leading-tight">
{productDetail?.title || product.name}
</h3>
<div className="flex flex-wrap gap-2 mb-4">
<span className="bg-orange-500 text-white px-4 py-1.5 rounded-full text-xs font-black uppercase tracking-wider">
Código: {productDetail?.idProduct || product.code}
</span>
{productDetail?.productType === "A" && (
<span className="bg-blue-600 text-white px-4 py-1.5 rounded-full text-xs font-bold">
Autosserviço
</span>
)}
{productDetail?.productType === "S" && (
<span className="bg-pink-600 text-white px-4 py-1.5 rounded-full text-xs font-bold">
Showroom
</span>
)}
{product.discount ? (
<span className="bg-emerald-500 text-white px-4 py-1.5 rounded-full text-xs font-black">
{product.discount.toFixed(0)}% OFF
</span>
) : null}
</div>
</div>
{/* Description */}
<div>
<h4 className="text-lg font-black text-[#002147] mb-2">
Descrição
</h4>
<p className="text-slate-600 text-sm leading-relaxed">
{productDetail?.description ||
product.description ||
"Sem descrição disponível"}
</p>
</div>
{/* Price */}
<div className="bg-slate-50 rounded-2xl p-6">
{product.originalPrice &&
product.originalPrice > product.price && (
<p className="text-sm text-slate-400 line-through mb-2">
de R${" "}
{product.originalPrice.toLocaleString("pt-BR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
)}
<div className="flex items-baseline gap-3 mb-2">
<p className="text-4xl font-black text-orange-600">
por R${" "}
{product.price.toLocaleString("pt-BR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
</div>
{productDetail?.installments &&
productDetail.installments.length > 0 && (
<p className="text-sm text-slate-600 mt-2">
ou em {productDetail.installments[0].installment}x de R${" "}
{productDetail.installments[0].installmentValue.toLocaleString(
"pt-BR",
{
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}
)}
</p>
)}
</div>
{/* Add to Cart Form */}
<div className="bg-white border-2 border-slate-200 rounded-2xl p-6 space-y-4">
<h4 className="text-lg font-black text-[#002147]">
Adicionar ao Carrinho
</h4>
{/* Quantity */}
<div>
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wide mb-2">
Quantidade <span className="text-red-500">*</span>
</label>
<div
className={`flex items-center border rounded-lg overflow-hidden w-fit ${
formErrors.quantity
? "border-red-500"
: "border-slate-300"
}`}
>
<button
onClick={() => {
const newQuantity = Math.max(0.01, quantity - 1);
setQuantity(newQuantity);
// Limpar erro ao alterar
if (formErrors.quantity) {
setFormErrors((prev) => ({
...prev,
quantity: undefined,
}));
}
}}
className="bg-[#002147] text-white px-4 py-3 hover:bg-[#003366] transition-colors font-bold flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed"
disabled={quantity <= 0.01}
>
<Minus className="w-4 h-4" />
</button>
<input
type="number"
min="0.01"
step="0.01"
value={quantity.toFixed(2)}
onChange={(e) => {
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" : ""
}`}
/>
<button
onClick={() => {
setQuantity((q) => q + 1);
// Limpar erro ao alterar
if (formErrors.quantity) {
setFormErrors((prev) => ({
...prev,
quantity: undefined,
}));
}
}}
className="bg-[#002147] text-white px-4 py-3 hover:bg-[#003366] transition-colors font-bold flex items-center justify-center"
>
<Plus className="w-4 h-4" />
</button>
</div>
{formErrors.quantity && (
<div className="flex items-center gap-1 mt-1 text-red-600 text-xs">
<AlertCircle className="w-3 h-3" />
<span>{formErrors.quantity}</span>
</div>
)}
</div>
{/* Stock Info */}
{productDetail?.stocks && productDetail.stocks.length > 0 && (
<div>
<FilialSelector
stocks={productDetail.stocks}
value={selectedStore}
onValueChange={(value) => {
setSelectedStore(value);
// Limpar erro ao alterar
if (formErrors.selectedStore) {
setFormErrors((prev) => ({
...prev,
selectedStore: undefined,
}));
}
}}
label="Filial Retira"
placeholder="Digite para buscar..."
/>
{formErrors.selectedStore && (
<div className="flex items-center gap-1 mt-1 text-red-600 text-xs">
<AlertCircle className="w-3 h-3" />
<span>{formErrors.selectedStore}</span>
</div>
)}
</div>
)}
{/* Delivery Type */}
<div>
<label className="block text-xs font-bold text-slate-700 uppercase tracking-wide mb-2">
Tipo de Entrega <span className="text-red-500">*</span>
</label>
<select
value={deliveryType}
onChange={(e) => {
setDeliveryType(e.target.value);
// Limpar erro ao alterar
if (formErrors.deliveryType) {
setFormErrors((prev) => ({
...prev,
deliveryType: undefined,
}));
}
}}
onBlur={() => {
// Validar ao sair do campo
if (!validateRequired(deliveryType)) {
setFormErrors((prev) => ({
...prev,
deliveryType: "O tipo de entrega é obrigatório",
}));
}
}}
className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500/20 text-sm font-medium ${
formErrors.deliveryType
? "border-red-500 focus:border-red-500"
: "border-slate-300 focus:border-orange-500"
}`}
required
>
<option value="">Selecione o tipo</option>
{deliveryTypes.map((dt) => (
<option key={dt.type} value={dt.type}>
{dt.description}
</option>
))}
</select>
{formErrors.deliveryType && (
<div className="flex items-center gap-1 mt-1 text-red-600 text-xs">
<AlertCircle className="w-3 h-3" />
<span>{formErrors.deliveryType}</span>
</div>
)}
</div>
{/* Add Button */}
<button
onClick={handleAddToCart}
className="w-full flex items-center justify-center gap-2 bg-orange-500 text-white py-4 rounded-xl font-black uppercase text-sm tracking-wider hover:bg-orange-600 transition-all shadow-lg"
>
<ShoppingCart className="w-5 h-5" />
Adicionar ao Carrinho
</button>
</div>
{/* Tabs */}
<div className="border-t border-slate-200 pt-6">
<div className="flex gap-4 mb-4">
<button
onClick={() => setActiveTab("details")}
className={`flex items-center gap-2 px-6 py-3 rounded-xl font-bold text-sm transition-all ${
activeTab === "details"
? "bg-[#002147] text-white"
: "bg-slate-100 text-slate-600 hover:bg-slate-200"
}`}
>
<Info className="w-4 h-4" />
Características Técnicas
</button>
<button
onClick={() => setActiveTab("specs")}
className={`flex items-center gap-2 px-6 py-3 rounded-xl font-bold text-sm transition-all ${
activeTab === "specs"
? "bg-[#002147] text-white"
: "bg-slate-100 text-slate-600 hover:bg-slate-200"
}`}
>
<FileText className="w-4 h-4" />
Especificações
</button>
</div>
{activeTab === "details" && (
<div className="bg-slate-50 rounded-xl p-6">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-xs font-bold text-slate-500 uppercase">
EAN
</span>
<p className="text-sm font-medium text-slate-800">
{product.ean || "N/A"}
</p>
</div>
<div>
<span className="text-xs font-bold text-slate-500 uppercase">
Marca
</span>
<p className="text-sm font-medium text-slate-800">
{product.mark || "N/A"}
</p>
</div>
<div>
<span className="text-xs font-bold text-slate-500 uppercase">
Estoque Loja
</span>
<p className="text-sm font-medium text-slate-800">
{product.stockLocal || 0} UN
</p>
</div>
<div>
<span className="text-xs font-bold text-slate-500 uppercase">
Estoque Disponível
</span>
<p className="text-sm font-medium text-slate-800">
{product.stockAvailable || 0} UN
</p>
</div>
</div>
</div>
)}
{activeTab === "specs" && (
<div className="bg-slate-50 rounded-xl p-6">
<p className="text-sm text-slate-600">
{productDetail?.technicalData ||
"Sem especificações técnicas disponíveis"}
</p>
</div>
)}
</div>
</div>
</div>
)}
{/* Compre Junto Section */}
{!loading && buyTogether.length > 0 && (
<div
ref={buyTogetherSectionRef}
className="mt-12 border-t border-slate-200 pt-8 scroll-mt-8"
>
<h3 className="text-2xl font-black text-[#002147] mb-6">
Compre Junto
</h3>
{loadingRelated ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-500"></div>
</div>
) : (
<div className="relative -mx-6 px-6">
{/* Botão de rolagem esquerda - sempre visível */}
<button
onClick={() =>
scrollHorizontally(
buyTogetherScrollRef,
"left",
setBuyTogetherScrollState
)
}
className={`absolute left-2 top-1/2 -translate-y-1/2 z-10 bg-white/95 hover:bg-white border border-slate-300 rounded-full p-3 shadow-xl transition-all hover:scale-110 ${
buyTogetherScrollState.canScrollLeft
? "opacity-100 cursor-pointer"
: "opacity-30 cursor-not-allowed"
}`}
disabled={!buyTogetherScrollState.canScrollLeft}
title="Rolar para esquerda"
>
<ChevronLeft className="w-5 h-5 text-[#002147]" />
</button>
{/* Container com produtos */}
<div
ref={buyTogetherScrollRef}
data-scroll-container
className="flex gap-4 overflow-x-auto scrollbar-hide scroll-smooth pb-4 cursor-grab active:cursor-grabbing"
style={{
WebkitOverflowScrolling: "touch",
touchAction: "pan-x",
scrollBehavior: "smooth",
overscrollBehaviorX: "contain",
willChange: "scroll-position",
}}
>
{buyTogether.map((relatedProduct) => {
const mappedProduct =
mapSaleProductToProduct(relatedProduct);
return (
<div
key={mappedProduct.id}
className="flex-shrink-0 w-[280px] min-w-[280px]"
>
<RelatedProductCard
product={mappedProduct}
onAddToCart={(p) =>
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",
});
}
}
}}
/>
</div>
);
})}
</div>
{/* Botão de rolagem direita - sempre visível quando há scroll */}
<button
onClick={() =>
scrollHorizontally(
buyTogetherScrollRef,
"right",
setBuyTogetherScrollState
)
}
className={`absolute right-2 top-1/2 -translate-y-1/2 z-10 bg-white/95 hover:bg-white border border-slate-300 rounded-full p-3 shadow-xl transition-all hover:scale-110 ${
buyTogetherScrollState.canScrollRight
? "opacity-100 cursor-pointer"
: "opacity-30 cursor-not-allowed"
}`}
disabled={!buyTogetherScrollState.canScrollRight}
title="Rolar para direita"
>
<ChevronRight className="w-5 h-5 text-[#002147]" />
</button>
</div>
)}
</div>
)}
{/* Produtos Similares */}
{!loading && similarProducts.length > 0 && (
<div
ref={similarProductsSectionRef}
className="mt-12 border-t border-slate-200 pt-8 scroll-mt-8"
>
<h3 className="text-2xl font-black text-[#002147] mb-6">
Produtos Similares
</h3>
{loadingRelated ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-500"></div>
</div>
) : (
<div className="relative -mx-6 px-6">
{/* Botão de rolagem esquerda - sempre visível quando há scroll */}
<button
onClick={() =>
scrollHorizontally(
similarProductsScrollRef,
"left",
setSimilarProductsScrollState
)
}
className={`absolute left-2 top-1/2 -translate-y-1/2 z-10 bg-white/95 hover:bg-white border border-slate-300 rounded-full p-3 shadow-xl transition-all hover:scale-110 ${
similarProductsScrollState.canScrollLeft
? "opacity-100 cursor-pointer"
: "opacity-30 cursor-not-allowed"
}`}
disabled={!similarProductsScrollState.canScrollLeft}
title="Rolar para esquerda"
>
<ChevronLeft className="w-5 h-5 text-[#002147]" />
</button>
{/* Container com produtos */}
<div
ref={similarProductsScrollRef}
data-scroll-container
className="flex gap-4 overflow-x-auto scrollbar-hide scroll-smooth pb-4 cursor-grab active:cursor-grabbing"
style={{
WebkitOverflowScrolling: "touch",
touchAction: "pan-x",
scrollBehavior: "smooth",
overscrollBehaviorX: "contain",
willChange: "scroll-position",
}}
>
{similarProducts.map((similarProduct) => {
const mappedProduct =
mapSaleProductToProduct(similarProduct);
return (
<div
key={mappedProduct.id}
className="flex-shrink-0 w-[280px] min-w-[280px]"
>
<RelatedProductCard
product={mappedProduct}
onAddToCart={(p) =>
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",
});
}
}
}}
/>
</div>
);
})}
</div>
{/* Botão de rolagem direita - sempre visível quando há scroll */}
<button
onClick={() =>
scrollHorizontally(
similarProductsScrollRef,
"right",
setSimilarProductsScrollState
)
}
className={`absolute right-2 top-1/2 -translate-y-1/2 z-10 bg-white/95 hover:bg-white border border-slate-300 rounded-full p-3 shadow-xl transition-all hover:scale-110 ${
similarProductsScrollState.canScrollRight
? "opacity-100 cursor-pointer"
: "opacity-30 cursor-not-allowed"
}`}
disabled={!similarProductsScrollState.canScrollRight}
title="Rolar para direita"
>
<ChevronRight className="w-5 h-5 text-[#002147]" />
</button>
</div>
)}
</div>
)}
</div>
</div>
<style>{`
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Garantir que o scroll horizontal funcione */
[data-scroll-container] {
overflow-x: auto !important;
overflow-y: visible !important;
-webkit-overflow-scrolling: touch;
touch-action: pan-x;
}
`}</style>
</div>
);
};
export default ProductDetailModal;