X3D_Web / static /script.js
Edoruin's picture
Initial clean commit with LFS
c2aab7f
// Script para visor 3D interactivo con integración a Google Sheets
// Carga productos desde hoja "Productos" y muestra STL correspondientes
// Configuración de Google Sheets (pueden ser sobrescritas por window.SPREADSHEET_ID y window.API_KEY)
const SPREADSHEET_ID = window.SPREADSHEET_ID || '1eCkidPqBRn48yK8sSFNmJ-qxu3us_K9c';
const API_KEY = window.API_KEY || 'AIzaSyCSEyIp3kVhPJZPeWI4TyGg4QJ2QMqYUDU';
// URL de la hoja de productos
const sheetUrl = window.sheetUrl || '/api/gs?action=products';
const ordersUrl = window.ordersUrl || '/api/gs?action=order';
// URL para actualizar inventario
const updateUrl = (row, column) => `https://sheets.googleapis.com/v4/spreadsheets/${SPREADSHEET_ID}/values/Products!${column}${row}:${column}${row}?key=${API_KEY}`;
// Variables globales
let scene, camera, renderer, controls;
let currentMesh = null;
let currentProduct = null;
let productsData = {};
let inventoryStatus = {};
let isFetching = false;
let fetchError = null;
// Variables del producto seleccionado
let quantity = 1;
let basePrice = 0;
let colorPrice = 0;
let currentMaterialColor = 0xffffff;
// Inicialización del visor 3D
function initThreeViewer() {
const container = document.getElementById('mainViewer');
if (!container) return;
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a0a);
camera = new THREE.PerspectiveCamera(50, container.clientWidth / container.clientHeight, 0.1, 1000);
camera.position.set(0, 5, 10);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(10, 10, 10);
scene.add(directionalLight);
const gridHelper = new THREE.GridHelper(20, 20, 0x404040, 0x303030);
scene.add(gridHelper);
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.autoRotate = true;
controls.autoRotateSpeed = 2;
animate();
}
// Animación del visor
function animate() {
requestAnimationFrame(animate);
if (controls) controls.update();
if (renderer && scene && camera) renderer.render(scene, camera);
}
// Cargar modelo STL
function loadModel(url) {
if (currentMesh) {
currentMesh.geometry.dispose();
currentMesh.material.dispose();
scene.remove(currentMesh);
currentMesh = null;
}
const loader = new THREE.STLLoader();
loader.load(
url,
(geometry) => {
try {
geometry.computeBoundingBox();
if (!geometry.boundingBox) {
console.warn('Geometry has no bounding box');
return;
}
const material = new THREE.MeshStandardMaterial({
color: currentMaterialColor,
metalness: 0.3,
roughness: 0.7
});
currentMesh = new THREE.Mesh(geometry, material);
currentMesh.position.set(0, 0, 0);
const box = geometry.boundingBox;
const center = box.getCenter(new THREE.Vector3());
geometry.translate(-center.x, -box.min.y, -center.z);
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = maxDim > 0 ? 5 / maxDim : 1;
currentMesh.scale.setScalar(scale);
scene.add(currentMesh);
// Mostrar controles de personalización
document.getElementById('productInfo').classList.remove('hidden');
} catch (e) {
console.error('Error loading model:', e);
showError('Error al cargar el modelo 3D');
}
},
undefined,
(error) => {
console.error('STL Load Error:', error);
showError('Error al cargar el archivo STL. Verifica que el archivo exista.');
}
);
}
// Cargar datos de productos desde Google Sheets
async function fetchProductsData() {
isFetching = true;
fetchError = null;
try {
const response = await fetch(sheetUrl, {
redirect: 'follow',
method: 'GET',
mode: 'cors'
});
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
if (data.values) {
data.values.forEach((row, index) => {
if (index === 0) return; // Saltar encabezado
const [product, price, life, inventory] = row;
if (product) {
const productName = product.toString().trim().toLowerCase();
productsData[productName] = {
name: product.toString().trim(),
price: parseFloat(price) || 0,
life: life,
inventory: parseInt(inventory) || 0,
file: `products/${productName.replace(/\s+/g, '_')}.stl`
};
}
});
console.log('Productos cargados:', productsData);
updateProductList();
}
} catch (e) {
console.error('Error fetching products:', e);
fetchError = e.message;
productsData = {};
showError('Error al cargar datos de productos');
} finally {
isFetching = false;
}
}
async function calculateProductPrice(productName) {
try {
const calculateUrl = sheetUrl.replace('action=products', 'action=calculate') + '&producto=' + encodeURIComponent(productName);
console.log('Calculando precio desde:', calculateUrl);
const response = await fetch(calculateUrl, {
redirect: 'follow',
method: 'GET',
mode: 'cors'
});
console.log('Response status:', response.status);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
console.log('Precio calculado (raw):', data);
const price = data.total_final || data.total_itbs || 0;
console.log('Precio final:', price);
return price;
} catch (e) {
console.error('Error calculating price:', e);
return 0;
}
}
// Actualizar lista de productos en el catálogo
function updateProductList() {
const productList = document.getElementById('productList');
if (!productList) return;
productList.innerHTML = '';
Object.values(productsData).forEach(product => {
const productCard = document.createElement('div');
productCard.className = 'col-span-6 sm:col-span-4 lg:col-span-3 p-3';
productCard.innerHTML = `
<div class="bg-neutral-900 rounded-2xl border border-neutral-800 p-6 cursor-pointer hover:shadow-lg transition-all"
onclick="selectProduct('${product.name}')">
<div class="text-center mb-4">
<div class="w-20 h-20 mx-auto bg-neutral-800 rounded-lg flex items-center justify-center">
<svg class="w-12 h-12 text-neutral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path>
</svg>
</div>
</div>
<h3 class="text-lg font-medium mb-2">${product.name}</h3>
<p class="text-sm text-neutral-400 mb-4">${product.inventory} disponibles</p>
<div class="flex items-baseline gap-2">
<span class="text-2xl font-display">RD$${product.price.toFixed(2)}</span>
<span class="text-neutral-500 text-sm">c/u</span>
</div>
</div>
`;
productList.appendChild(productCard);
});
if (Object.keys(productsData).length === 0) {
productList.innerHTML = `
<div class="col-span-full text-center py-8 text-neutral-500">
No hay productos disponibles. Sube archivos .STL a la carpeta products/ y actualiza la hoja de productos.
</div>
`;
}
}
// Seleccionar producto del catálogo
async function selectProduct(productName) {
currentProduct = productsData[productName.toLowerCase()];
if (!currentProduct) return;
// Cargar modelo 3D
loadModel(currentProduct.file);
// Calcular precio con Apps Script
const calculatedPrice = await calculateProductPrice(currentProduct.name);
if (calculatedPrice > 0) {
currentProduct.price = calculatedPrice;
}
// Actualizar información del producto
updateProductInfo();
// Habilitar botón de agregar al carrito
document.getElementById('addToCartBtn').disabled = false;
document.getElementById('addToCartBtn').classList.remove('cursor-not-allowed', 'bg-neutral-700', 'text-neutral-400');
document.getElementById('addToCartBtn').classList.add('bg-neutral-700', 'text-neutral-400');
document.getElementById('addToCartBtn').textContent = 'Selecciona un producto';
}
// Actualizar información del producto seleccionado
function updateProductInfo() {
if (!currentProduct) return;
document.getElementById('selectedProductName').textContent = currentProduct.name;
document.getElementById('productPrice').textContent = `RD$${currentProduct.price.toFixed(2)}`;
document.getElementById('availableCount').textContent = currentProduct.inventory;
document.getElementById('unitPrice').textContent = `RD$${currentProduct.price.toFixed(2)}`;
basePrice = currentProduct.price;
updatePrice();
}
// Cambiar color del modelo 3D
function changeColor(color) {
currentMaterialColor = color;
if (currentMesh && currentMesh.material) {
currentMesh.material.dispose();
const material = new THREE.MeshStandardMaterial({
color: color,
metalness: 0.3,
roughness: 0.7
});
currentMesh.material = material;
}
}
// Actualizar precio total
function updatePrice() {
const total = (basePrice + colorPrice) * quantity;
document.getElementById('productPrice').textContent = `RD$${total.toFixed(2)}`;
const addBtn = document.getElementById('addToCartBtn');
if (addBtn && !addBtn.disabled) {
addBtn.textContent = `Ñadir al Carrito - RD$${total.toFixed(2)}`;
}
}
// Actualizar cantidad
function updateQuantity(delta) {
quantity = Math.max(1, quantity + delta);
document.getElementById('quantity').value = quantity;
updatePrice();
}
// Enviar pedido a Google Sheets
async function sendOrderToSheet() {
if (!currentProduct) {
alert('Por favor, selecciona un producto primero');
return;
}
const clientName = prompt('Ingresa el nombre del cliente:', '');
if (!clientName) return;
const clientAddress = prompt('Ingresa la dirección de entrega:', '');
if (!clientAddress) return;
const clientPhone = prompt('Ingresa el número de teléfono:', '');
if (!clientPhone) return;
const orderData = {
producto: currentProduct.name,
cantidad: quantity,
cliente: clientName,
direccion: clientAddress,
telefono: clientPhone,
fecha: new Date().toISOString(),
precio: (basePrice + colorPrice) * quantity
};
try {
showLoading();
const orderUrl = sheetUrl.replace('action=products', 'action=order') +
'&product=' + encodeURIComponent(orderData.producto) +
'&quantity=' + orderData.cantidad +
'&client=' + encodeURIComponent(orderData.cliente) +
'&address=' + encodeURIComponent(orderData.direccion) +
'&phone=' + encodeURIComponent(orderData.telefono) +
'&fecha=' + encodeURIComponent(orderData.fecha);
const response = await fetch(orderUrl, {
redirect: 'follow',
method: 'GET',
mode: 'cors'
});
if (!response.ok) {
throw new Error('Error al enviar el pedido');
}
const result = await response.json();
hideLoading();
alert('¡Pedido enviado exitosamente!' +
`
• Producto: ${orderData.producto}
• Cantidad: ${orderData.cantidad}
• Cliente: ${orderData.cliente}
• Dirección: ${orderData.direccion}
• Teléfono: ${orderData.telefono}
• Total: RD$${result.total || orderData.precio.toFixed(2)}`);
// Download invoice PDF
const invoiceUrl = \`/api/invoice?product=${encodeURIComponent(orderData.producto)}&quantity=${orderData.cantidad}&client=${encodeURIComponent(orderData.cliente)}&phone=${encodeURIComponent(orderData.telefono)}&price=${orderData.precio}&pedido_id=${result.pedidoId}\`;
window.location.href = invoiceUrl;
// Redirect to home after 2 seconds
setTimeout(() => {
window.location.href = '/visor-3d';
}, 2000);
} catch (e) {
console.error('Error sending order:', e);
hideLoading();
showError('Error al enviar el pedido. Inténtalo de nuevo.');
}
}
}
// Actualizar inventario en Google Sheets
async function updateInventory(productName, quantity) {
try {
const sheetUrl = '/api/gs?action=products';
const response = await fetch(sheetUrl);
const data = await response.json();
if (data.values) {
for (let i = 1; i < data.values.length; i++) {
const row = data.values[i];
if (row[0] && row[0].toLowerCase() === productName.toLowerCase()) {
const currentInv = parseInt(row[3]) || 0;
const newInv = currentInv + quantity;
// Actualizar en la hoja
const updateUrl = `https://sheets.googleapis.com/v4/spreadsheets/${SPREADSHEET_ID}/values/Products!D${i+1}:D${i+1}?key=${API_KEY}`;
await fetch(updateUrl, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
values: [[newInv]]
})
});
// Actualizar datos locales
currentProduct.inventory = newInv;
updateProductInfo();
break;
}
}
}
} catch (e) {
console.error('Error updating inventory:', e);
}
}
// Mostrar overlay de carga
function showLoading() {
const loadingEl = document.getElementById('loadingOverlay');
if (loadingEl) loadingEl.classList.remove('hidden');
}
// Ocultar overlay de carga
function hideLoading() {
const loadingEl = document.getElementById('loadingOverlay');
if (loadingEl) loadingEl.classList.add('hidden');
}
// Mostrar mensaje de error
function showError(message) {
const errorEl = document.getElementById('errorMessage');
const errorContainer = document.getElementById('errorMessages');
if (errorEl) errorEl.textContent = message;
if (errorContainer) errorContainer.classList.remove('hidden');
}
// Ocultar mensaje de error
function hideError() {
const errorContainer = document.getElementById('errorMessages');
if (errorContainer) errorContainer.classList.add('hidden');
}
// Inicializar al cargar la página
window.addEventListener('DOMContentLoaded', async () => {
// Inicializar visor 3D
initThreeViewer();
// Cargar productos desde Google Sheets
await fetchProductsData();
// Verificar si hay error
if (fetchError) {
console.warn('Error al cargar productos:', fetchError);
}
});
// Event listeners para colores
document.querySelectorAll('.color-swatch').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.color-swatch').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
colorPrice = parseFloat(btn.dataset.price) || 0;
updatePrice();
});
});
// Only run if addToCartBtn exists (visor-3d page)
const addToCartBtn = document.getElementById('addToCartBtn');
if (addToCartBtn) {
addToCartBtn.addEventListener('click', sendOrderToSheet);
}