1324 lines
52 KiB
TypeScript
1324 lines
52 KiB
TypeScript
|
|
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;
|