Details now show real customer API data
This commit is contained in:
@@ -2,7 +2,11 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run lint)",
|
||||
"WebSearch"
|
||||
"WebSearch",
|
||||
"WebFetch(domain:www.impuls.com.mx)",
|
||||
"Bash(del \"C:\\Users\\jandres\\source\\repos\\test\\my-app\\iframe-test.html\")",
|
||||
"WebFetch(domain:baymard.com)",
|
||||
"Bash(npx shadcn@latest add:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
60
app/api/products/[productId]/route.ts
Normal file
60
app/api/products/[productId]/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: { productId: string } }) {
|
||||
try {
|
||||
const { productId } = params;
|
||||
|
||||
// Validate productId
|
||||
if (!productId || typeof productId !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Product ID is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch from Impuls API
|
||||
const apiUrl = `https://www.impuls.com.mx/api/catalog_system/pub/products/search/?fq=productId:${productId}`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`API Error: ${response.status} ${response.statusText}`);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch product from external API' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Return first product from array or null if empty
|
||||
const product = Array.isArray(data) && data.length > 0 ? data[0] : null;
|
||||
|
||||
if (!product) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Product not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(product, {
|
||||
headers: {
|
||||
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600'
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('API Route Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ export default function HomePage() {
|
||||
const [videoDevices, setVideoDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('');
|
||||
|
||||
const [activeShoe, setActiveShoe] = useState<Shoe | null>(null);
|
||||
const [isPopupOpen, setPopupOpen] = useState(false);
|
||||
const [isHistoryOpen, setHistoryOpen] = useState(false);
|
||||
const [history, setHistory] = useState<Shoe[]>([]);
|
||||
@@ -120,7 +119,6 @@ export default function HomePage() {
|
||||
const handleScan = () => {
|
||||
const detected = detectShoe(SHOE_DATABASE);
|
||||
if (detected) {
|
||||
setActiveShoe(detected);
|
||||
const updatedHistory = addToHistory(detected);
|
||||
setHistory(updatedHistory);
|
||||
setPopupOpen(true);
|
||||
@@ -128,7 +126,6 @@ export default function HomePage() {
|
||||
};
|
||||
|
||||
const handleHistoryItemClick = (shoe: Shoe) => {
|
||||
setActiveShoe(shoe);
|
||||
setHistoryOpen(false);
|
||||
setPopupOpen(true);
|
||||
};
|
||||
@@ -462,7 +459,7 @@ export default function HomePage() {
|
||||
return (
|
||||
<main className="relative h-screen w-screen bg-black overflow-hidden">
|
||||
{renderContent()}
|
||||
<ShoeResultsPopup isOpen={isPopupOpen} onOpenChange={setPopupOpen} shoe={activeShoe} />
|
||||
<ShoeResultsPopup isOpen={isPopupOpen} onOpenChange={setPopupOpen} />
|
||||
<HistorySidebar isOpen={isHistoryOpen} onOpenChange={setHistoryOpen} history={history} onItemClick={handleHistoryItemClick} />
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -5,52 +5,108 @@ import { useState, useEffect } from 'react';
|
||||
import { Drawer } from 'vaul';
|
||||
import { type CarouselApi } from "@/components/ui/carousel";
|
||||
import { Carousel, CarouselContent, CarouselItem } from "@/components/ui/carousel";
|
||||
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Shoe } from '@/lib/shoe-database';
|
||||
import { ExternalLink, Package, Truck, Shield, Star } from 'lucide-react';
|
||||
import { fetchProduct, getProductImages, getProductPricing, getProductVariants, type Product } from '@/lib/product-api';
|
||||
|
||||
interface ShoeResultsPopupProps {
|
||||
shoe: Shoe | null;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export default function ShoeResultsPopup({ shoe, isOpen, onOpenChange }: ShoeResultsPopupProps) {
|
||||
export default function ShoeResultsPopup({ isOpen, onOpenChange }: ShoeResultsPopupProps) {
|
||||
const [api, setApi] = useState<CarouselApi>();
|
||||
const [activeImageUrl, setActiveImageUrl] = useState(shoe?.imageUrl);
|
||||
const [allImages, setAllImages] = useState<string[]>([]);
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeImageUrl, setActiveImageUrl] = useState<string>('');
|
||||
const [selectedVariant, setSelectedVariant] = useState<string>('');
|
||||
const [selectedSize, setSelectedSize] = useState<string>('');
|
||||
|
||||
// Fetch product data when popup opens
|
||||
useEffect(() => {
|
||||
if (shoe) {
|
||||
const images = [shoe.imageUrl, ...shoe.colorOptions.map(o => o.imageUrl)].filter(Boolean) as string[];
|
||||
setAllImages(images);
|
||||
setActiveImageUrl(shoe.imageUrl);
|
||||
api?.scrollTo(0, true); // Jump to start without animation
|
||||
if (isOpen && !product) {
|
||||
setLoading(true);
|
||||
fetchProduct().then((data) => {
|
||||
if (data) {
|
||||
setProduct(data);
|
||||
const images = getProductImages(data);
|
||||
if (images.length > 0) {
|
||||
setActiveImageUrl(images[0]);
|
||||
}
|
||||
// Set first available variant
|
||||
const variants = getProductVariants(data);
|
||||
if (variants.length > 0) {
|
||||
setSelectedVariant(variants[0].itemId);
|
||||
if (variants[0].sizes.length > 0) {
|
||||
setSelectedSize(variants[0].sizes[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [shoe, api]);
|
||||
}, [isOpen, product]);
|
||||
|
||||
// Handle carousel selection
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
if (!api || !product) return;
|
||||
|
||||
const handleSelect = () => {
|
||||
const selectedIndex = api.selectedScrollSnap();
|
||||
setActiveImageUrl(allImages[selectedIndex]);
|
||||
const images = getProductImages(product);
|
||||
setActiveImageUrl(images[selectedIndex]);
|
||||
};
|
||||
|
||||
api.on("select", handleSelect);
|
||||
return () => {
|
||||
api.off("select", handleSelect);
|
||||
};
|
||||
}, [api, allImages]);
|
||||
}, [api, product]);
|
||||
|
||||
const handleThumbnailClick = (imageUrl: string) => {
|
||||
const index = allImages.findIndex(url => url === imageUrl);
|
||||
if (!product) return;
|
||||
const images = getProductImages(product);
|
||||
const index = images.findIndex(url => url === imageUrl);
|
||||
if (index !== -1) {
|
||||
api?.scrollTo(index);
|
||||
}
|
||||
};
|
||||
|
||||
if (!shoe) return null;
|
||||
const handleViewDetails = () => {
|
||||
if (product?.linkText) {
|
||||
window.open(`https://www.impuls.com.mx/${product.linkText}/p`, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Drawer.Root open={isOpen} onOpenChange={onOpenChange}>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100]" />
|
||||
<Drawer.Content className="fixed bottom-0 left-0 right-0 mt-24 flex h-[90%] flex-col rounded-t-3xl bg-black/90 backdrop-blur-xl border-t-2 border-white/20 z-[101] shadow-2xl">
|
||||
<Drawer.Title className="sr-only">Cargando producto</Drawer.Title>
|
||||
<Drawer.Description className="sr-only">Obteniendo información del producto</Drawer.Description>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-12 h-12 border-4 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<p className="text-white/80 text-lg">Cargando producto...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
);
|
||||
}
|
||||
|
||||
if (!product) return null;
|
||||
|
||||
const images = getProductImages(product);
|
||||
const pricing = getProductPricing(product);
|
||||
const variants = getProductVariants(product);
|
||||
const selectedVariantData = variants.find(v => v.itemId === selectedVariant);
|
||||
|
||||
return (
|
||||
<Drawer.Root open={isOpen} onOpenChange={onOpenChange}>
|
||||
@@ -61,103 +117,166 @@ export default function ShoeResultsPopup({ shoe, isOpen, onOpenChange }: ShoeRes
|
||||
<div className="sticky top-0 z-10 w-full bg-black/80 backdrop-blur-xl rounded-t-[24px] py-6 border-b border-white/10">
|
||||
<div className="mx-auto h-1.5 w-16 flex-shrink-0 rounded-full bg-white/40 shadow-lg" />
|
||||
</div>
|
||||
<Drawer.Title className="sr-only">{shoe.name}</Drawer.Title>
|
||||
<Drawer.Description className="sr-only">{shoe.description}</Drawer.Description>
|
||||
<Drawer.Title className="sr-only">{product.productName}</Drawer.Title>
|
||||
<Drawer.Description className="sr-only">{product.description}</Drawer.Description>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Image Carousel */}
|
||||
<div className="bg-white/5 backdrop-blur-sm rounded-2xl p-4 border border-white/10">
|
||||
<Carousel setApi={setApi} className="w-full">
|
||||
<CarouselContent>
|
||||
{allImages.map((url, index) => (
|
||||
<CarouselItem key={index}>
|
||||
<div className="relative h-72 w-full overflow-hidden rounded-xl bg-white/10 backdrop-blur-sm border border-white/20 md:h-80">
|
||||
<Image
|
||||
src={url}
|
||||
alt={`${shoe.name} image ${index + 1}`}
|
||||
layout="fill"
|
||||
objectFit="contain"
|
||||
className="p-4"
|
||||
/>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
{/* Brand Badge */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="secondary" className="bg-white/10 text-white border-white/20">
|
||||
{product.brand}
|
||||
</Badge>
|
||||
{pricing.discount > 0 && (
|
||||
<Badge className="bg-red-500/20 text-red-300 border-red-500/30">
|
||||
-{pricing.discount}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Color Options */}
|
||||
<div className="bg-white/5 backdrop-blur-sm rounded-2xl p-6 border border-white/10">
|
||||
<h3 className="text-xl font-bold text-white mb-4 flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-blue-400 rounded-full"></div>
|
||||
Colores Disponibles
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{shoe.colorOptions.map((option) => (
|
||||
<button
|
||||
key={option.color}
|
||||
onClick={() => handleThumbnailClick(option.imageUrl)}
|
||||
className={`group relative h-20 overflow-hidden rounded-xl border-2 transition-all duration-300 hover:scale-105 ${
|
||||
activeImageUrl === option.imageUrl
|
||||
? 'border-blue-400 shadow-lg shadow-blue-500/25'
|
||||
: 'border-white/20 hover:border-white/40'
|
||||
}`}
|
||||
>
|
||||
<Image src={option.imageUrl} alt={option.color} layout="fill" objectFit="cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
||||
<div className="absolute bottom-1 left-1 right-1">
|
||||
<p className="text-white text-xs font-medium truncate drop-shadow-lg">
|
||||
{option.color}
|
||||
</p>
|
||||
</div>
|
||||
{activeImageUrl === option.imageUrl && (
|
||||
<div className="absolute top-1 right-1 w-3 h-3 bg-blue-400 rounded-full border border-white"></div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Info */}
|
||||
<div className="bg-white/5 backdrop-blur-sm rounded-2xl p-6 border border-white/10">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 mb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-white md:text-4xl">
|
||||
{shoe.name}
|
||||
</h1>
|
||||
{shoe.promotions && shoe.promotions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{shoe.promotions.map((promotion, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`px-3 py-1 rounded-full text-xs font-bold border ${
|
||||
promotion.type === 'discount'
|
||||
? 'bg-red-500/20 text-red-300 border-red-500/30'
|
||||
: promotion.type === 'new'
|
||||
? 'bg-green-500/20 text-green-300 border-green-500/30'
|
||||
: 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30'
|
||||
}`}
|
||||
>
|
||||
{promotion.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline gap-4 mb-4">
|
||||
<p className="text-4xl font-bold bg-gradient-to-r from-green-400 to-emerald-400 bg-clip-text text-transparent">
|
||||
${shoe.price.toFixed(2)}
|
||||
{/* Product Name & Price */}
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white md:text-3xl leading-tight">
|
||||
{product.productName}
|
||||
</h1>
|
||||
<div className="flex items-baseline gap-4">
|
||||
<p className="text-3xl font-bold bg-gradient-to-r from-green-400 to-emerald-400 bg-clip-text text-transparent">
|
||||
${pricing.price.toFixed(2)}
|
||||
</p>
|
||||
{shoe.promotions?.find(p => p.originalPrice) && (
|
||||
<p className="text-2xl text-gray-500 line-through">
|
||||
${shoe.promotions.find(p => p.originalPrice)?.originalPrice?.toFixed(2)}
|
||||
{pricing.listPrice > pricing.price && (
|
||||
<p className="text-xl text-gray-500 line-through">
|
||||
${pricing.listPrice.toFixed(2)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-lg text-white/80 leading-relaxed">
|
||||
{shoe.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Image Carousel */}
|
||||
<div className="bg-white/5 backdrop-blur-sm rounded-2xl p-4 border border-white/10">
|
||||
{images.length > 0 ? (
|
||||
<Carousel setApi={setApi} className="w-full">
|
||||
<CarouselContent>
|
||||
{images.map((url, index) => (
|
||||
<CarouselItem key={index}>
|
||||
<div className="relative h-72 w-full overflow-hidden rounded-xl bg-white/10 backdrop-blur-sm border border-white/20 md:h-80">
|
||||
<Image
|
||||
src={url}
|
||||
alt={`${product.productName} imagen ${index + 1}`}
|
||||
layout="fill"
|
||||
objectFit="contain"
|
||||
className="p-4"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
) : (
|
||||
<div className="relative h-72 w-full overflow-hidden rounded-xl bg-white/10 backdrop-blur-sm border border-white/20 md:h-80 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4 text-white/60">
|
||||
<Package size={48} />
|
||||
<p className="text-lg font-medium">Imagen no disponible</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Size Selection */}
|
||||
{selectedVariantData && selectedVariantData.sizes.length > 0 && (
|
||||
<div className="bg-white/5 backdrop-blur-sm rounded-2xl p-6 border border-white/10">
|
||||
<h3 className="text-xl font-bold text-white mb-4 flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-blue-400 rounded-full"></div>
|
||||
Tallas Disponibles
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{selectedVariantData.sizes.map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => setSelectedSize(size)}
|
||||
className={`h-12 rounded-lg border-2 transition-all duration-300 hover:scale-105 flex items-center justify-center font-medium ${
|
||||
selectedSize === size
|
||||
? 'border-blue-400 bg-blue-400/20 text-blue-300 shadow-lg shadow-blue-500/25'
|
||||
: 'border-white/20 bg-white/5 text-white hover:border-white/40 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product Details Accordion */}
|
||||
<div className="bg-white/5 backdrop-blur-sm rounded-2xl border border-white/10 overflow-hidden">
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="description" className="border-white/10">
|
||||
<AccordionTrigger className="px-6 py-4 text-white hover:no-underline hover:bg-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Star className="w-4 h-4" />
|
||||
<span className="font-semibold">Descripción</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-4 text-white/80 leading-relaxed">
|
||||
{product.description || 'Descripción no disponible.'}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="variants" className="border-white/10">
|
||||
<AccordionTrigger className="px-6 py-4 text-white hover:no-underline hover:bg-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="w-4 h-4" />
|
||||
<span className="font-semibold">Variantes ({variants.length})</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-4">
|
||||
<div className="space-y-3">
|
||||
{variants.map((variant) => (
|
||||
<div key={variant.itemId} className="flex items-center justify-between p-3 bg-white/5 rounded-lg border border-white/10">
|
||||
<div className="text-white font-medium">{variant.name}</div>
|
||||
<Badge variant={variant.isAvailable ? "default" : "secondary"}>
|
||||
{variant.isAvailable ? 'Disponible' : 'Agotado'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="shipping" className="border-white/10">
|
||||
<AccordionTrigger className="px-6 py-4 text-white hover:no-underline hover:bg-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Truck className="w-4 h-4" />
|
||||
<span className="font-semibold">Envío y Devoluciones</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-4 text-white/80">
|
||||
<div className="space-y-2">
|
||||
<p>• Envío gratuito en compras mayores a $999</p>
|
||||
<p>• Entrega en 3-5 días hábiles</p>
|
||||
<p>• Devoluciones gratuitas dentro de 30 días</p>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="warranty" className="border-white/10 border-b-0">
|
||||
<AccordionTrigger className="px-6 py-4 text-white hover:no-underline hover:bg-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span className="font-semibold">Garantía</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-4 text-white/80">
|
||||
<div className="space-y-2">
|
||||
<p>• Garantía del fabricante de 6 meses</p>
|
||||
<p>• Protección contra defectos de manufactura</p>
|
||||
<p>• Soporte técnico especializado</p>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,8 +284,13 @@ export default function ShoeResultsPopup({ shoe, isOpen, onOpenChange }: ShoeRes
|
||||
{/* Action Button */}
|
||||
<div className="sticky bottom-0 w-full border-t border-white/10 bg-black/80 backdrop-blur-xl p-6">
|
||||
<div className="flex gap-3">
|
||||
<Button size="lg" className="flex-1 h-14 text-lg bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 border-0 shadow-lg shadow-blue-500/25 transform hover:scale-[1.02] transition-all duration-200">
|
||||
🛒 Añadir al Carrito
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleViewDetails}
|
||||
className="flex-1 h-14 text-lg bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 border-0 shadow-lg shadow-blue-500/25 transform hover:scale-[1.02] transition-all duration-200"
|
||||
>
|
||||
<ExternalLink className="w-5 h-5 mr-2" />
|
||||
Ver Detalles Completos
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" className="h-14 px-6 bg-white/5 border-white/20 text-white hover:bg-white/10 hover:border-white/30">
|
||||
❤️
|
||||
@@ -177,4 +301,4 @@ export default function ShoeResultsPopup({ shoe, isOpen, onOpenChange }: ShoeRes
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
);
|
||||
}
|
||||
}
|
||||
66
components/ui/accordion.tsx
Normal file
66
components/ui/accordion.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
28
components/ui/separator.tsx
Normal file
28
components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
149
lib/product-api.ts
Normal file
149
lib/product-api.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
export interface ProductImage {
|
||||
imageId: string;
|
||||
imageLabel: string;
|
||||
imageTag: string;
|
||||
imageUrl: string;
|
||||
imageText: string;
|
||||
imageLastModified: string;
|
||||
}
|
||||
|
||||
export interface CommercialOffer {
|
||||
Price: number;
|
||||
ListPrice: number;
|
||||
PriceWithoutDiscount: number;
|
||||
FullSellingPrice: number;
|
||||
RewardValue: number;
|
||||
PriceValidUntil: string;
|
||||
AvailableQuantity: number;
|
||||
IsAvailable: boolean;
|
||||
GiftSkuIds: string[];
|
||||
Installments: {
|
||||
Value: number;
|
||||
InterestRate: number;
|
||||
TotalValuePlusInterestRate: number;
|
||||
NumberOfInstallments: number;
|
||||
}[];
|
||||
discountHighLight: unknown[];
|
||||
teasers: unknown[];
|
||||
}
|
||||
|
||||
export interface ProductSeller {
|
||||
sellerId: string;
|
||||
sellerName: string;
|
||||
addToCartLink: string;
|
||||
sellerDefault: boolean;
|
||||
commertialOffer: CommercialOffer;
|
||||
}
|
||||
|
||||
export interface ProductItem {
|
||||
itemId: string;
|
||||
name: string;
|
||||
nameComplete: string;
|
||||
complementName: string;
|
||||
ean: string;
|
||||
referenceId: { Key: string; Value: string }[];
|
||||
measurementUnit: string;
|
||||
unitMultiplier: number;
|
||||
modalType: null;
|
||||
isKit: boolean;
|
||||
images: ProductImage[];
|
||||
Talla: string[];
|
||||
variations: string[];
|
||||
sellers: ProductSeller[];
|
||||
Videos: unknown[];
|
||||
estimatedDateArrival: null;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
productId: string;
|
||||
productName: string;
|
||||
brand: string;
|
||||
brandId: number;
|
||||
brandImageUrl: null | string;
|
||||
linkText: string;
|
||||
productReference: string;
|
||||
productReferenceCode: string;
|
||||
categoryId: string;
|
||||
productTitle: string;
|
||||
metaTagDescription: string;
|
||||
releaseDate: string;
|
||||
clusterHighlights: Record<string, unknown>;
|
||||
productClusters: Record<string, string>;
|
||||
searchableClusters: Record<string, unknown>;
|
||||
categories: string[];
|
||||
categoriesIds: string[];
|
||||
link: string;
|
||||
description: string;
|
||||
items: ProductItem[];
|
||||
}
|
||||
|
||||
const FIXED_PRODUCT_ID = '169520';
|
||||
|
||||
export async function fetchProduct(productId: string = FIXED_PRODUCT_ID): Promise<Product | null> {
|
||||
try {
|
||||
// Use our Next.js API route as a proxy to avoid CORS issues
|
||||
const response = await fetch(`/api/products/${productId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
console.error('Failed to fetch product:', response.status, errorData);
|
||||
return null;
|
||||
}
|
||||
|
||||
const product = await response.json();
|
||||
return product;
|
||||
} catch (error) {
|
||||
console.error('Error fetching product:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getProductImages(product: Product): string[] {
|
||||
const allImages: string[] = [];
|
||||
|
||||
product.items.forEach(item => {
|
||||
item.images.forEach(image => {
|
||||
if (image.imageUrl && !allImages.includes(image.imageUrl)) {
|
||||
allImages.push(image.imageUrl);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return allImages;
|
||||
}
|
||||
|
||||
export function getProductPricing(product: Product) {
|
||||
const firstAvailableItem = product.items.find(item =>
|
||||
item.sellers.some(seller => seller.commertialOffer.IsAvailable)
|
||||
);
|
||||
|
||||
if (!firstAvailableItem) {
|
||||
return { price: 0, listPrice: 0, discount: 0, isAvailable: false };
|
||||
}
|
||||
|
||||
const seller = firstAvailableItem.sellers.find(s => s.commertialOffer.IsAvailable);
|
||||
if (!seller) {
|
||||
return { price: 0, listPrice: 0, discount: 0, isAvailable: false };
|
||||
}
|
||||
|
||||
const { Price, ListPrice } = seller.commertialOffer;
|
||||
const discount = ListPrice > Price ? Math.round(((ListPrice - Price) / ListPrice) * 100) : 0;
|
||||
|
||||
return {
|
||||
price: Price,
|
||||
listPrice: ListPrice,
|
||||
discount,
|
||||
isAvailable: true
|
||||
};
|
||||
}
|
||||
|
||||
export function getProductVariants(product: Product) {
|
||||
return product.items.map(item => ({
|
||||
itemId: item.itemId,
|
||||
name: item.name,
|
||||
sizes: item.Talla,
|
||||
images: item.images.map(img => img.imageUrl),
|
||||
isAvailable: item.sellers.some(seller => seller.commertialOffer.IsAvailable),
|
||||
price: item.sellers.find(s => s.commertialOffer.IsAvailable)?.commertialOffer.Price || 0
|
||||
}));
|
||||
}
|
||||
@@ -4,10 +4,13 @@ const nextConfig: NextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https'
|
||||
,
|
||||
protocol: 'https',
|
||||
hostname: 'placehold.co',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'b2cimpulsmx.vteximg.com.br',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
86
package-lock.json
generated
86
package-lock.json
generated
@@ -8,8 +8,10 @@
|
||||
"name": "my-app",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -966,6 +968,37 @@
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-accordion": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
|
||||
"integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||
@@ -989,6 +1022,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collapsible": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
|
||||
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||
@@ -1327,6 +1390,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
|
||||
"integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
Reference in New Issue
Block a user