852 lines
41 KiB
TypeScript
852 lines
41 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
import { useSearchParams } from 'next/navigation';
|
|
import { Camera, History, VideoOff, Settings, Video } from 'lucide-react';
|
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Slider } from '@/components/ui/slider';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { type Shoe } from '@/lib/shoe-database';
|
|
import { addToHistory, getHistory } from '@/lib/history-storage';
|
|
import ShoeResultsPopup from '@/components/shoe-results-popup';
|
|
import HistorySidebar from '@/components/history-sidebar';
|
|
import { useDetection } from '@/lib/ml/use-detection';
|
|
import type { DetectionResult } from '@/lib/ml/types';
|
|
|
|
type CameraStatus = 'loading' | 'active' | 'denied' | 'no_devices';
|
|
|
|
export default function HomePage() {
|
|
const searchParams = useSearchParams();
|
|
const isDev = searchParams.get('dev') === 'true';
|
|
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const [stream, setStream] = useState<MediaStream | null>(null);
|
|
const [cameraStatus, setCameraStatus] = useState<CameraStatus>('loading');
|
|
|
|
const [videoDevices, setVideoDevices] = useState<MediaDeviceInfo[]>([]);
|
|
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('');
|
|
|
|
const [isPopupOpen, setPopupOpen] = useState(false);
|
|
const [isHistoryOpen, setHistoryOpen] = useState(false);
|
|
const [history, setHistory] = useState<Shoe[]>([]);
|
|
const [detectedSKU, setDetectedSKU] = useState<string | null>(null);
|
|
const [isSettingsPanelOpen, setSettingsPanelOpen] = useState(false);
|
|
|
|
// ML Detection state
|
|
const [detectionEnabled, setDetectionEnabled] = useState(true); // Auto-enable on page load
|
|
const [currentDetection, setCurrentDetection] = useState<DetectionResult | null>(null);
|
|
const [shoeDetectionCount, setShoeDetectionCount] = useState(0);
|
|
const lastSoundTimeRef = useRef(0);
|
|
|
|
|
|
// Initialize ML detection system first
|
|
const {
|
|
isLoading: isMLLoading,
|
|
metrics,
|
|
error: mlError,
|
|
initialize: initializeML,
|
|
startContinuous,
|
|
stopContinuous,
|
|
triggerDetection,
|
|
updateConfig,
|
|
config,
|
|
detectionEngine,
|
|
setDetectionCallback
|
|
} = useDetection({
|
|
modelVariant: 'standard', // Start with standard model
|
|
enableContinuous: true,
|
|
enableTrigger: true,
|
|
onDetection: undefined, // Will be set after handleDetection is defined
|
|
onError: (error) => {
|
|
console.error('ML Detection Error:', error);
|
|
}
|
|
});
|
|
|
|
// Clean detection callback - no canvas drawing needed
|
|
const handleDetection = useCallback(async (detection: DetectionResult | null) => {
|
|
const callbackId = Math.random().toString(36).substr(2, 9);
|
|
console.log(`🔍 Detection callback received [${callbackId}]:`, detection);
|
|
setCurrentDetection(detection);
|
|
|
|
// Count actual shoe detections (not just inference attempts)
|
|
if (detection) {
|
|
setShoeDetectionCount(prev => prev + 1);
|
|
}
|
|
|
|
// Auto-trigger popup when shoe is detected with high confidence
|
|
if (detection && detection.confidence > 0.7) {
|
|
console.log(`🎯 HIGH CONFIDENCE SHOE DETECTED! [${callbackId}] Opening popup...`, detection);
|
|
|
|
// Call SKU identification API
|
|
if (videoRef.current && detectionEngine) {
|
|
try {
|
|
console.log(`🔍 [${callbackId}] Calling SKU identification...`);
|
|
const sku = await detectionEngine.identifyProductSKU(videoRef.current);
|
|
console.log(`📦 [${callbackId}] SKU result:`, sku);
|
|
setDetectedSKU(sku);
|
|
|
|
if (sku) {
|
|
// Create shoe object with SKU for history
|
|
const shoeWithSKU: Shoe = {
|
|
id: Date.now().toString(),
|
|
name: `Producto ${sku}`,
|
|
brand: 'Identificado por IA',
|
|
price: 'Precio por consultar',
|
|
image: '/placeholder.jpg',
|
|
confidence: detection.confidence,
|
|
sku: sku,
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
|
|
const updatedHistory = addToHistory(shoeWithSKU);
|
|
setHistory(updatedHistory);
|
|
}
|
|
} catch (error) {
|
|
console.error(`❌ [${callbackId}] SKU identification failed:`, error);
|
|
setDetectedSKU(null);
|
|
}
|
|
}
|
|
|
|
setPopupOpen(true);
|
|
|
|
// Play detection sound with debouncing (max once per 2 seconds)
|
|
const now = Date.now();
|
|
const lastTime = lastSoundTimeRef.current;
|
|
console.log(`🔊 Sound check [${callbackId}]: now=${now}, lastTime=${lastTime}, diff=${now - lastTime}ms`);
|
|
|
|
if (now - lastTime > 2000) {
|
|
try {
|
|
const audioId = Math.random().toString(36).substr(2, 9);
|
|
// Use AudioContext for more reliable single-play behavior
|
|
const AudioContextClass = window.AudioContext || (window as typeof window & { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
|
const audioContext = new AudioContextClass();
|
|
console.log(`🔊 Playing detection sound [callback:${callbackId}] [audio:${audioId}]`);
|
|
|
|
// Simple beep using Web Audio API
|
|
const oscillator = audioContext.createOscillator();
|
|
const gainNode = audioContext.createGain();
|
|
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(audioContext.destination);
|
|
|
|
oscillator.frequency.setValueAtTime(800, audioContext.currentTime);
|
|
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
|
gainNode.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.3);
|
|
|
|
oscillator.start(audioContext.currentTime);
|
|
oscillator.stop(audioContext.currentTime + 0.3);
|
|
|
|
console.log(`▶️ Audio beep started [${audioId}]`);
|
|
lastSoundTimeRef.current = now;
|
|
} catch (e) {
|
|
console.warn(`Sound playback failed [${callbackId}]:`, e);
|
|
}
|
|
} else {
|
|
console.log(`🔇 Sound skipped [${callbackId}] - too soon after last sound (${now - lastTime}ms ago)`);
|
|
}
|
|
}
|
|
}, [detectionEngine]);
|
|
|
|
// Set the detection callback after handleDetection is defined
|
|
useEffect(() => {
|
|
if (setDetectionCallback && handleDetection) {
|
|
setDetectionCallback(handleDetection);
|
|
}
|
|
}, [handleDetection, setDetectionCallback]);
|
|
|
|
// Effect to clean up the stream when component unmounts or stream changes
|
|
useEffect(() => {
|
|
return () => {
|
|
stream?.getTracks().forEach((track) => track.stop());
|
|
};
|
|
}, [stream]);
|
|
|
|
// Effect to assign stream to video when videoRef becomes available
|
|
useEffect(() => {
|
|
if (videoRef.current && stream && cameraStatus === 'active') {
|
|
console.log('Assigning saved stream to video element');
|
|
videoRef.current.srcObject = stream;
|
|
}
|
|
}, [stream, cameraStatus]); // Runs when stream or camera status changes
|
|
|
|
|
|
// Track initialization state to prevent multiple attempts
|
|
const [mlInitialized, setMLInitialized] = useState(false);
|
|
|
|
// Initialize ML detection when camera is ready (only once)
|
|
useEffect(() => {
|
|
// Only log in development and when conditions change meaningfully
|
|
if (process.env.NODE_ENV === 'development') {
|
|
console.log('🔍 ML init check:', {
|
|
ready: videoRef.current && cameraStatus === 'active' && !isMLLoading && detectionEnabled && !mlInitialized
|
|
});
|
|
}
|
|
|
|
if (videoRef.current && cameraStatus === 'active' && !isMLLoading && detectionEnabled && !mlInitialized) {
|
|
console.log('✅ Starting ML detection...');
|
|
setMLInitialized(true);
|
|
|
|
initializeML(videoRef.current).then(() => {
|
|
console.log('✅ ML ready, starting continuous detection');
|
|
startContinuous();
|
|
}).catch((error) => {
|
|
console.error('❌ ML initialization failed:', error);
|
|
setMLInitialized(false); // Reset on error to allow retry
|
|
});
|
|
}
|
|
}, [cameraStatus, detectionEnabled, isMLLoading, mlInitialized, initializeML, startContinuous]);
|
|
|
|
const startStream = useCallback(async (deviceId: string) => {
|
|
// Stop previous stream if it exists
|
|
(videoRef.current?.srcObject as MediaStream)?.getTracks().forEach((track) => track.stop());
|
|
console.log('starting stream....')
|
|
try {
|
|
const newStream = await navigator.mediaDevices.getUserMedia({
|
|
video: { deviceId: { exact: deviceId } },
|
|
});
|
|
|
|
console.log(newStream)
|
|
|
|
// Always save the stream first
|
|
setStream(newStream);
|
|
setCameraStatus('active');
|
|
|
|
if (videoRef.current) {
|
|
// Video element is ready, assign stream immediately
|
|
videoRef.current.srcObject = newStream;
|
|
} else {
|
|
// Video element not ready yet, stream will be assigned when it mounts
|
|
console.log('Video element not ready, stream will be assigned when video mounts');
|
|
}
|
|
} catch (err) {
|
|
console.error('Error starting stream: ', err);
|
|
setCameraStatus('denied');
|
|
}
|
|
}, []);
|
|
|
|
// Effect to automatically initialize camera on app start
|
|
useEffect(() => {
|
|
const initializeCamera = async () => {
|
|
try {
|
|
// First request permissions to get stable deviceIds
|
|
const permissionStream = await navigator.mediaDevices.getUserMedia({ video: true });
|
|
permissionStream.getTracks().forEach(track => track.stop()); // Stop immediately
|
|
|
|
// Now enumerate devices - deviceIds will be stable after permission granted
|
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
const videoInputs = devices.filter(d => d.kind === 'videoinput');
|
|
setVideoDevices(videoInputs); // Always populate the dropdown
|
|
|
|
if (videoInputs.length === 0) {
|
|
setCameraStatus('no_devices');
|
|
return;
|
|
}
|
|
|
|
// Load history
|
|
setHistory(getHistory());
|
|
|
|
// Try to use stored device, otherwise use first available
|
|
const storedDeviceId = localStorage.getItem('selectedCameraDeviceId');
|
|
const targetDevice = (storedDeviceId && videoInputs.find(d => d.deviceId === storedDeviceId))
|
|
|| videoInputs[0];
|
|
|
|
setSelectedDeviceId(targetDevice.deviceId);
|
|
localStorage.setItem('selectedCameraDeviceId', targetDevice.deviceId);
|
|
await startStream(targetDevice.deviceId);
|
|
|
|
} catch (err) {
|
|
// Permission denied or no camera
|
|
console.error('Error accessing camera or enumerating devices:', err);
|
|
setCameraStatus('denied');
|
|
}
|
|
};
|
|
|
|
initializeCamera();
|
|
}, [startStream]); // Auto-run on mount
|
|
|
|
|
|
const handleCameraChange = (deviceId: string) => {
|
|
console.log('SAVING: ',deviceId)
|
|
setSelectedDeviceId(deviceId);
|
|
localStorage.setItem('selectedCameraDeviceId', deviceId); // Save stable deviceId
|
|
startStream(deviceId);
|
|
};
|
|
|
|
const handleScan = async () => {
|
|
if (detectionEnabled && triggerDetection) {
|
|
try {
|
|
console.log('🎯 Triggering ML detection...');
|
|
const mlResult = await triggerDetection();
|
|
|
|
if (mlResult) {
|
|
console.log('✅ Shoe detected by ML, calling SKU identification...');
|
|
|
|
// Call SKU identification with the detected shoe
|
|
if (videoRef.current && detectionEngine) {
|
|
try {
|
|
const sku = await detectionEngine.identifyProductSKU(videoRef.current);
|
|
console.log('📦 Manual scan SKU result:', sku);
|
|
setDetectedSKU(sku);
|
|
|
|
if (sku) {
|
|
// Create shoe object with SKU for history
|
|
const shoeWithSKU: Shoe = {
|
|
id: Date.now().toString(),
|
|
name: `Producto ${sku}`,
|
|
brand: 'Identificado por IA',
|
|
price: 'Precio por consultar',
|
|
image: '/placeholder.jpg',
|
|
confidence: mlResult.confidence,
|
|
sku: sku,
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
|
|
const updatedHistory = addToHistory(shoeWithSKU);
|
|
setHistory(updatedHistory);
|
|
}
|
|
|
|
setPopupOpen(true);
|
|
} catch (skuError) {
|
|
console.error('❌ SKU identification failed:', skuError);
|
|
// Still show popup even if SKU fails
|
|
setDetectedSKU(null);
|
|
setPopupOpen(true);
|
|
}
|
|
} else {
|
|
console.warn('⚠️ Video or detection engine not available for SKU call');
|
|
setPopupOpen(true);
|
|
}
|
|
} else {
|
|
console.log('❌ No shoe detected by ML');
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ ML detection failed:', error);
|
|
}
|
|
} else {
|
|
console.log('⚠️ ML detection is disabled');
|
|
}
|
|
};
|
|
|
|
const handleHistoryItemClick = () => {
|
|
setHistoryOpen(false);
|
|
setPopupOpen(true);
|
|
};
|
|
|
|
|
|
const renderContent = () => {
|
|
switch (cameraStatus) {
|
|
case 'loading':
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-gray-900 via-black to-gray-800">
|
|
{/* Modern Loading Skeleton */}
|
|
<div className="relative w-full h-full">
|
|
{/* Animated Background Gradient */}
|
|
<div className="absolute inset-0 bg-gradient-to-br from-blue-900/20 via-purple-900/20 to-gray-900/20"></div>
|
|
|
|
{/* Pulsing Camera Icon */}
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<div className="relative">
|
|
{/* Outer Ring */}
|
|
<div className="w-32 h-32 rounded-full border-4 border-white/20 animate-pulse"></div>
|
|
{/* Middle Ring */}
|
|
<div className="absolute inset-4 w-24 h-24 rounded-full border-2 border-blue-500/40 animate-ping"></div>
|
|
{/* Inner Ring */}
|
|
<div className="absolute inset-8 w-16 h-16 rounded-full border-2 border-white/60 animate-pulse"></div>
|
|
{/* Camera Icon */}
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<Camera size={32} className="text-white animate-pulse" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Loading Text */}
|
|
<div className="absolute bottom-1/3 left-1/2 transform -translate-x-1/2 text-center">
|
|
<div className="mb-4">
|
|
<div className="flex space-x-1 justify-center">
|
|
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0s' }}></div>
|
|
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
|
|
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
|
</div>
|
|
</div>
|
|
<h2 className="text-xl font-bold text-white mb-2 animate-pulse">Iniciando Smart Store Asistant: Scan</h2>
|
|
<p className="text-gray-400 animate-pulse">Accediendo a la cámara...</p>
|
|
</div>
|
|
|
|
{/* Skeleton UI Elements */}
|
|
<div className="absolute top-6 left-6 right-6">
|
|
{/* Top Bar Skeleton */}
|
|
<div className="flex justify-between items-center mb-4">
|
|
<div className="w-64 h-12 bg-white/10 rounded-lg animate-pulse"></div>
|
|
<div className="w-12 h-12 bg-white/10 rounded-lg animate-pulse"></div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom Button Skeleton */}
|
|
<div className="absolute bottom-20 left-1/2 transform -translate-x-1/2">
|
|
<div className="w-16 h-16 bg-white/20 rounded-full animate-pulse"></div>
|
|
</div>
|
|
|
|
{/* Particle Effect - Fixed positions to avoid hydration mismatch */}
|
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
|
{[
|
|
{ left: 10, top: 20, delay: 0, duration: 2.5 },
|
|
{ left: 80, top: 15, delay: 0.3, duration: 3 },
|
|
{ left: 25, top: 70, delay: 0.8, duration: 2.2 },
|
|
{ left: 90, top: 40, delay: 1.2, duration: 2.8 },
|
|
{ left: 5, top: 85, delay: 0.5, duration: 3.2 },
|
|
{ left: 70, top: 25, delay: 1.5, duration: 2.4 },
|
|
{ left: 40, top: 60, delay: 0.2, duration: 2.9 },
|
|
{ left: 85, top: 75, delay: 1.8, duration: 2.6 },
|
|
{ left: 15, top: 45, delay: 0.7, duration: 3.1 },
|
|
{ left: 60, top: 10, delay: 1.1, duration: 2.3 },
|
|
{ left: 30, top: 90, delay: 0.4, duration: 2.7 },
|
|
{ left: 95, top: 55, delay: 1.6, duration: 2.1 },
|
|
{ left: 50, top: 30, delay: 0.9, duration: 2.8 },
|
|
{ left: 20, top: 65, delay: 0.1, duration: 3.3 },
|
|
{ left: 75, top: 80, delay: 1.3, duration: 2.2 },
|
|
{ left: 35, top: 5, delay: 0.6, duration: 2.9 },
|
|
{ left: 65, top: 50, delay: 1.4, duration: 2.5 },
|
|
{ left: 8, top: 35, delay: 1.7, duration: 3.1 },
|
|
{ left: 88, top: 95, delay: 0.3, duration: 2.4 },
|
|
{ left: 45, top: 22, delay: 1.9, duration: 2.7 }
|
|
].map((particle, i) => (
|
|
<div
|
|
key={i}
|
|
className="absolute w-1 h-1 bg-blue-500/30 rounded-full animate-ping"
|
|
style={{
|
|
left: `${particle.left}%`,
|
|
top: `${particle.top}%`,
|
|
animationDelay: `${particle.delay}s`,
|
|
animationDuration: `${particle.duration}s`,
|
|
}}
|
|
></div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
case 'active':
|
|
return (
|
|
<>
|
|
<video ref={videoRef} autoPlay playsInline muted onCanPlay={() => videoRef.current?.play()} className="h-full w-full object-cover" />
|
|
|
|
{/* Impuls Logo - Top Left */}
|
|
<div className="absolute top-3 left-6 z-10 pointer-events-none">
|
|
<img
|
|
src="/Impuls Logo.png"
|
|
alt="Impuls Logo"
|
|
className="h-12 w-auto opacity-80"
|
|
style={{ filter: 'brightness(0) invert(1)' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Simple Camera Frame Overlay - Like Reference Image */}
|
|
<div className="absolute inset-0 pointer-events-none">
|
|
{/* Simple Corner Frames */}
|
|
<div className="absolute inset-0">
|
|
{/* Top Left Corner */}
|
|
<div className="absolute w-8 h-8" style={{ top: '30%', left: '25%' }}>
|
|
<div className="absolute top-0 left-0 w-6 h-0.5 bg-white opacity-80"></div>
|
|
<div className="absolute top-0 left-0 w-0.5 h-6 bg-white opacity-80"></div>
|
|
</div>
|
|
|
|
{/* Top Right Corner */}
|
|
<div className="absolute w-8 h-8" style={{ top: '30%', right: '25%' }}>
|
|
<div className="absolute top-0 right-0 w-6 h-0.5 bg-white opacity-80"></div>
|
|
<div className="absolute top-0 right-0 w-0.5 h-6 bg-white opacity-80"></div>
|
|
</div>
|
|
|
|
{/* Bottom Left Corner */}
|
|
<div className="absolute w-8 h-8" style={{ bottom: '45%', left: '25%' }}>
|
|
<div className="absolute bottom-0 left-0 w-6 h-0.5 bg-white opacity-80"></div>
|
|
<div className="absolute bottom-0 left-0 w-0.5 h-6 bg-white opacity-80"></div>
|
|
</div>
|
|
|
|
{/* Bottom Right Corner */}
|
|
<div className="absolute w-8 h-8" style={{ bottom: '45%', right: '25%' }}>
|
|
<div className="absolute bottom-0 right-0 w-6 h-0.5 bg-white opacity-80"></div>
|
|
<div className="absolute bottom-0 right-0 w-0.5 h-6 bg-white opacity-80"></div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Center Rotating 3D Shoe Logo */}
|
|
<div className="absolute w-16 h-16" style={{ top: '42.5%', left: '50%', transform: 'translate(-50%, -50%)' }}>
|
|
{/* 3D Rotating Shoe Logo */}
|
|
<div className="relative w-full h-full" style={{
|
|
transformStyle: 'preserve-3d',
|
|
animation: 'rotate3D 6s infinite linear'
|
|
}}>
|
|
{/* Shoe Logo Image */}
|
|
<img
|
|
src="/zapatillas.png"
|
|
alt="Shoe Logo"
|
|
className="absolute inset-0 w-full h-full object-contain opacity-60"
|
|
style={{ filter: 'brightness(0) invert(1)' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 3D Animation Keyframes */}
|
|
<style jsx>{`
|
|
@keyframes rotate3D {
|
|
0% { transform: rotateY(0deg) rotateX(0deg); }
|
|
25% { transform: rotateY(90deg) rotateX(15deg); }
|
|
50% { transform: rotateY(180deg) rotateX(0deg); }
|
|
75% { transform: rotateY(270deg) rotateX(-15deg); }
|
|
100% { transform: rotateY(360deg) rotateX(0deg); }
|
|
}
|
|
`}</style>
|
|
</div>
|
|
|
|
{/* Detection Counters - Only show in dev mode */}
|
|
{isDev && (
|
|
<div className="absolute top-4 right-4 bg-black/60 backdrop-blur-sm rounded-lg px-3 py-2 text-white text-xs space-y-1">
|
|
<div className="text-green-400">👟 Shoes Found: {shoeDetectionCount}</div>
|
|
<div className="text-blue-400">⚡ Avg Speed: {metrics?.inferenceTime ? `${metrics.inferenceTime.toFixed(0)}ms` : 'N/A'}</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Settings Panel */}
|
|
<div className={`absolute left-0 top-0 bottom-0 w-80 bg-black/80 backdrop-blur-xl border-r border-white/20 transform transition-transform duration-500 ease-out z-40 ${
|
|
isSettingsPanelOpen ? 'translate-x-0' : '-translate-x-full'
|
|
}`}>
|
|
<div className="p-6 h-full flex flex-col">
|
|
{/* Panel Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
|
<Settings size={24} className="text-blue-400" />
|
|
Configuración
|
|
</h2>
|
|
<button
|
|
onClick={() => setSettingsPanelOpen(false)}
|
|
className="text-white/60 hover:text-white transition-colors p-1"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
{/* Camera Selection */}
|
|
<div className="mb-6">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<Video size={20} className="text-blue-400" />
|
|
<label className="text-white font-medium">Cámara</label>
|
|
</div>
|
|
<Select value={selectedDeviceId} onValueChange={handleCameraChange}>
|
|
<SelectTrigger className="w-full bg-white/10 border-white/20 text-white hover:bg-white/20 transition-colors">
|
|
<SelectValue placeholder="Seleccionar cámara..." />
|
|
</SelectTrigger>
|
|
<SelectContent className="bg-black/90 backdrop-blur-xl border-white/20">
|
|
{videoDevices.map((device) => (
|
|
<SelectItem key={device.deviceId} value={device.deviceId} className="text-white hover:bg-white/20">
|
|
{device.label || `Cámara ${videoDevices.indexOf(device) + 1}`}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Additional Options */}
|
|
<div className="space-y-4 flex-1">
|
|
|
|
|
|
{/* ML Detection Settings - Only show in dev mode */}
|
|
{isDev && (
|
|
<div className="bg-white/5 rounded-lg p-4 border border-white/10">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<Camera size={20} className="text-blue-400" />
|
|
<span className="text-white font-medium">Detección IA</span>
|
|
{isMLLoading && <span className="text-xs text-yellow-400">Cargando...</span>}
|
|
</div>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => {
|
|
setDetectionEnabled(!detectionEnabled);
|
|
if (!detectionEnabled) {
|
|
console.log('Enabling ML detection');
|
|
} else {
|
|
console.log('Disabling ML detection');
|
|
stopContinuous();
|
|
}
|
|
}}
|
|
className={`text-sm py-2 px-4 rounded-md transition-colors border ${
|
|
detectionEnabled
|
|
? 'bg-green-500/20 text-green-300 border-green-500/30 hover:bg-green-500/30'
|
|
: 'bg-white/10 text-white border-white/20 hover:bg-blue-500/30'
|
|
}`}
|
|
>
|
|
{detectionEnabled ? 'Activado' : 'Activar'}
|
|
</button>
|
|
<div className="flex items-center gap-2">
|
|
{detectionEnabled && (
|
|
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
|
)}
|
|
<span className="text-white/60 text-sm">
|
|
{detectionEnabled ? 'Detectando zapatos automáticamente' : 'Click para activar detección IA'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ML Metrics */}
|
|
{detectionEnabled && metrics && (
|
|
<div className="text-xs space-y-1 text-white/50 bg-black/20 p-2 rounded">
|
|
<div>FPS: {metrics.fps.toFixed(1)}</div>
|
|
<div>Inferencia: {metrics.inferenceTime.toFixed(0)}ms</div>
|
|
{metrics.memoryUsage > 0 && <div>Memoria: {metrics.memoryUsage.toFixed(0)}MB</div>}
|
|
</div>
|
|
)}
|
|
|
|
{/* Detection Confidence Indicator */}
|
|
{detectionEnabled && currentDetection && (
|
|
<div className="space-y-2 pt-2">
|
|
<label className="text-sm font-medium text-white/80">Confianza de Detección</label>
|
|
<div className="bg-white/10 rounded-lg p-3 border border-white/20">
|
|
<div className="flex justify-between items-center mb-1">
|
|
<span className="text-xs text-white/60">Confianza</span>
|
|
<span className="text-xs text-white font-bold">{(currentDetection.confidence * 100).toFixed(1)}%</span>
|
|
</div>
|
|
<div className="w-full bg-black/30 rounded-full h-2">
|
|
<div
|
|
className={`h-2 rounded-full transition-all duration-300 ${
|
|
currentDetection.confidence > 0.8 ? 'bg-green-500' :
|
|
currentDetection.confidence > 0.6 ? 'bg-yellow-500' : 'bg-red-500'
|
|
}`}
|
|
style={{ width: `${currentDetection.confidence * 100}%` }}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-between text-xs text-white/40 mt-1">
|
|
<span>Bajo</span>
|
|
<span>Alto</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Other settings */}
|
|
{detectionEnabled && config && (
|
|
<div className="space-y-4 pt-4">
|
|
<div>
|
|
<label className="text-sm font-medium text-white/80">Sensibilidad ({(config.confidenceThreshold * 100).toFixed(0)}%)</label>
|
|
<Slider
|
|
min={0.3}
|
|
max={0.9}
|
|
step={0.05}
|
|
value={[config.confidenceThreshold]}
|
|
onValueChange={([value]) => updateConfig({ confidenceThreshold: value })}
|
|
disabled={!detectionEnabled}
|
|
className="mt-2"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium text-white/80">Frames a saltar ({config.frameSkip})</label>
|
|
<Slider
|
|
min={1}
|
|
max={10}
|
|
step={1}
|
|
value={[config.frameSkip]}
|
|
onValueChange={([value]) => updateConfig({ frameSkip: value })}
|
|
disabled={!detectionEnabled}
|
|
className="mt-2"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm font-medium text-white/80">Detección continua</label>
|
|
<Switch
|
|
checked={config.enableContinuous}
|
|
onCheckedChange={(checked) => updateConfig({ enableContinuous: checked })}
|
|
disabled={!detectionEnabled}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm font-medium text-white/80">Detección por trigger</label>
|
|
<Switch
|
|
checked={config.enableTrigger}
|
|
onCheckedChange={(checked) => updateConfig({ enableTrigger: checked })}
|
|
disabled={!detectionEnabled}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Detection Status */}
|
|
{currentDetection && (
|
|
<div className="text-xs bg-green-500/10 text-green-300 p-2 rounded border border-green-500/20">
|
|
🎯 Zapato detectado (confianza: {(currentDetection.confidence * 100).toFixed(1)}%)
|
|
</div>
|
|
)}
|
|
|
|
{mlError && (
|
|
<div className="text-xs bg-red-500/10 text-red-300 p-2 rounded border border-red-500/20">
|
|
❌ {mlError}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
|
|
{/* App Info */}
|
|
<div className="bg-gradient-to-r from-blue-500/10 to-purple-500/10 rounded-lg p-4 border border-blue-500/20">
|
|
<h3 className="text-white font-medium mb-2">Smart Store Assistant</h3>
|
|
<p className="text-white/70 text-sm">
|
|
Detector inteligente de Calzado con IA avanzada
|
|
</p>
|
|
<div className="mt-3 text-xs text-blue-300">
|
|
v0.0.3 • Powered by Moonshot
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="pt-4 border-t border-white/10">
|
|
<button className="w-full bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 text-white font-medium py-2 px-4 rounded-lg transition-all duration-200 transform hover:scale-105">
|
|
Guardar Configuración
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modern Glassmorphism Dock at Bottom */}
|
|
<div className={`absolute bottom-6 left-1/2 transform -translate-x-1/2 z-50 transition-all duration-300 ${
|
|
isPopupOpen || isHistoryOpen || isSettingsPanelOpen ? 'opacity-0 translate-y-4 pointer-events-none' : 'opacity-100 translate-y-0'
|
|
}`}>
|
|
<div className="relative">
|
|
{/* Dock Container */}
|
|
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-3xl px-8 py-4 shadow-2xl">
|
|
<div className="flex items-center space-x-6">
|
|
|
|
{/* Settings Button */}
|
|
<button
|
|
onClick={() => setSettingsPanelOpen(!isSettingsPanelOpen)}
|
|
className='group relative'
|
|
>
|
|
<div className="w-12 h-12 bg-gradient-to-br from-blue-500/30 to-purple-500/30 rounded-full flex items-center justify-center border border-white/30 hover:from-blue-500/50 hover:to-purple-500/50 transition-all duration-300 transform hover:scale-110 hover:rotate-12">
|
|
<Settings size={20} className="text-white drop-shadow-sm" />
|
|
</div>
|
|
<div className="absolute -top-10 left-1/2 transform -translate-x-1/2 bg-black/80 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
|
Ajustes
|
|
</div>
|
|
{/* Glow Effect */}
|
|
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 rounded-full blur opacity-0 group-hover:opacity-100 transition-opacity duration-300 transform scale-150"></div>
|
|
</button>
|
|
|
|
{/* Main Capture Button - Larger */}
|
|
<button
|
|
onClick={handleScan}
|
|
className='group relative'
|
|
>
|
|
<div className="w-16 h-16 bg-gradient-to-br from-red-500/40 to-pink-500/40 rounded-full flex items-center justify-center border-2 border-white/40 hover:from-red-500/60 hover:to-pink-500/60 transition-all duration-300 transform hover:scale-110 shadow-2xl">
|
|
<div className="w-12 h-12 bg-white/20 rounded-full flex items-center justify-center">
|
|
<Camera size={28} className="text-white drop-shadow-lg" />
|
|
</div>
|
|
</div>
|
|
<div className="absolute -top-12 left-1/2 transform -translate-x-1/2 bg-black/80 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap">
|
|
Detectar Zapato
|
|
</div>
|
|
{/* Pulsing Ring */}
|
|
<div className="absolute inset-0 rounded-full border-2 border-red-400/50 animate-ping"></div>
|
|
{/* Glow Effect */}
|
|
<div className="absolute inset-0 bg-gradient-to-br from-red-500/30 to-pink-500/30 rounded-full blur opacity-0 group-hover:opacity-100 transition-opacity duration-300 transform scale-150"></div>
|
|
</button>
|
|
|
|
{/* History Button */}
|
|
<button
|
|
onClick={() => setHistoryOpen(true)}
|
|
className='group relative'
|
|
>
|
|
<div className="w-12 h-12 bg-gradient-to-br from-green-500/30 to-emerald-500/30 rounded-full flex items-center justify-center border border-white/30 hover:from-green-500/50 hover:to-emerald-500/50 transition-all duration-300 transform hover:scale-110 hover:rotate-12">
|
|
<History size={20} className="text-white drop-shadow-sm" />
|
|
</div>
|
|
<div className="absolute -top-10 left-1/2 transform -translate-x-1/2 bg-black/80 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
|
Historial
|
|
</div>
|
|
{/* Glow Effect */}
|
|
<div className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 rounded-full blur opacity-0 group-hover:opacity-100 transition-opacity duration-300 transform scale-150"></div>
|
|
</button>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dock Shadow/Reflection */}
|
|
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-full h-4 bg-gradient-to-b from-white/5 to-transparent rounded-b-3xl blur-sm"></div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
case 'no_devices':
|
|
return (
|
|
<div className="flex h-full w-full flex-col items-center justify-center bg-gradient-to-br from-gray-900 via-black to-gray-800">
|
|
<div className="text-center p-8">
|
|
<div className="relative mb-6">
|
|
<div className="w-24 h-24 mx-auto rounded-full bg-red-500/20 flex items-center justify-center mb-4">
|
|
<VideoOff size={48} className="text-red-400" />
|
|
</div>
|
|
<div className="absolute inset-0 w-24 h-24 mx-auto rounded-full border-2 border-red-400/30 animate-ping"></div>
|
|
</div>
|
|
<h1 className="text-2xl font-bold text-white mb-3">No se encontraron cámaras</h1>
|
|
<p className="text-gray-400 text-lg mb-6 max-w-md mx-auto">
|
|
Asegúrate de que tu cámara esté conectada y funcionando correctamente.
|
|
</p>
|
|
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 max-w-sm mx-auto">
|
|
<p className="text-red-300 text-sm">
|
|
💡 Verifica la conexión de tu cámara web o cámara integrada
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
case 'denied':
|
|
return (
|
|
<div className="flex h-full w-full flex-col items-center justify-center bg-gradient-to-br from-gray-900 via-black to-gray-800">
|
|
<div className="text-center p-8">
|
|
<div className="relative mb-6">
|
|
<div className="w-24 h-24 mx-auto rounded-full bg-yellow-500/20 flex items-center justify-center mb-4">
|
|
<Camera size={48} className="text-yellow-400" />
|
|
</div>
|
|
<div className="absolute inset-0 w-24 h-24 mx-auto rounded-full border-2 border-yellow-400/30 animate-pulse"></div>
|
|
</div>
|
|
<h1 className="text-2xl font-bold text-white mb-3">Acceso a la cámara requerido</h1>
|
|
<p className="text-gray-400 text-lg mb-6 max-w-md mx-auto">
|
|
Necesitamos acceso a tu cámara para detectar zapatos.
|
|
</p>
|
|
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-lg p-4 max-w-sm mx-auto mb-4">
|
|
<p className="text-yellow-300 text-sm mb-2">
|
|
🔒 Habilita el permiso de cámara:
|
|
</p>
|
|
<ol className="text-yellow-300/80 text-xs text-left space-y-1">
|
|
<li>1. Haz clic en el ícono de la cámara en la barra de direcciones</li>
|
|
<li>2. Selecciona "Permitir"</li>
|
|
<li>3. Recarga la página</li>
|
|
</ol>
|
|
</div>
|
|
<button
|
|
onClick={() => window.location.reload()}
|
|
className='bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition-colors'
|
|
>
|
|
Recargar página
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
default:
|
|
return (
|
|
<div className="flex h-full w-full flex-col items-center justify-center bg-gray-900 text-white">
|
|
<div className="text-center">
|
|
<VideoOff size={48} className="mb-4 text-gray-500 mx-auto" />
|
|
<h1 className="text-xl font-semibold mb-2">Estado desconocido</h1>
|
|
<p className="text-gray-400">Por favor, recarga la página</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<main className="relative h-screen w-screen bg-black overflow-hidden">
|
|
{renderContent()}
|
|
<ShoeResultsPopup isOpen={isPopupOpen} onOpenChange={setPopupOpen} detectedSKU={detectedSKU} />
|
|
<HistorySidebar isOpen={isHistoryOpen} onOpenChange={setHistoryOpen} history={history} onItemClick={handleHistoryItemClick} />
|
|
</main>
|
|
);
|
|
} |