Skip to content

Instantly share code, notes, and snippets.

@Klerith
Created July 22, 2025 16:20
Show Gist options
  • Save Klerith/15c09dd610d17b9548de24a01056f828 to your computer and use it in GitHub Desktop.
Save Klerith/15c09dd610d17b9548de24a01056f828 to your computer and use it in GitHub Desktop.
Pantalla de Producto
// https://github.com/Klerith/bolt-product-editor
import { AdminTitle } from '@/admin/components/AdminTitle';
import { useParams } from 'react-router';
import { useState } from 'react';
import { X, Plus, Upload, Tag, SaveAll } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Link } from 'react-router';
interface Product {
id: string;
title: string;
price: number;
description: string;
slug: string;
stock: number;
sizes: string[];
gender: string;
tags: string[];
images: string[];
}
export const AdminProductPage = () => {
const { id } = useParams();
const productTitle = id === 'new' ? 'Nuevo producto' : 'Editar producto';
const productSubtitle =
id === 'new'
? 'Aquí puedes crear un nuevo producto.'
: 'Aquí puedes editar el producto.';
const [product, setProduct] = useState<Product>({
id: '376e23ed-df37-4f88-8f84-4561da5c5d46',
title: "Men's Raven Lightweight Hoodie",
price: 115,
description:
"Introducing the Tesla Raven Collection. The Men's Raven Lightweight Hoodie has a premium, relaxed silhouette made from a sustainable bamboo cotton blend. The hoodie features subtle thermoplastic polyurethane Tesla logos across the chest and on the sleeve with a french terry interior for versatility in any season. Made from 70% bamboo and 30% cotton.",
slug: 'men_raven_lightweight_hoodie',
stock: 10,
sizes: ['XS', 'S', 'M', 'L', 'XL', 'XXL'],
gender: 'men',
tags: ['hoodie'],
images: [
'https://placehold.co/250x250',
'https://placehold.co/250x250',
'https://placehold.co/250x250',
'https://placehold.co/250x250',
],
});
const [newTag, setNewTag] = useState('');
const [dragActive, setDragActive] = useState(false);
const availableSizes = ['XXS', 'XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'];
const handleInputChange = (field: keyof Product, value: string | number) => {
setProduct((prev) => ({ ...prev, [field]: value }));
};
const addTag = () => {
if (newTag.trim() && !product.tags.includes(newTag.trim())) {
setProduct((prev) => ({
...prev,
tags: [...prev.tags, newTag.trim()],
}));
setNewTag('');
}
};
const removeTag = (tagToRemove: string) => {
setProduct((prev) => ({
...prev,
tags: prev.tags.filter((tag) => tag !== tagToRemove),
}));
};
const addSize = (size: string) => {
if (!product.sizes.includes(size)) {
setProduct((prev) => ({
...prev,
sizes: [...prev.sizes, size],
}));
}
};
const removeSize = (sizeToRemove: string) => {
setProduct((prev) => ({
...prev,
sizes: prev.sizes.filter((size) => size !== sizeToRemove),
}));
};
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
const files = e.dataTransfer.files;
console.log(files);
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
console.log(files);
};
return (
<>
<div className="flex justify-between items-center">
<AdminTitle title={productTitle} subtitle={productSubtitle} />
<div className="flex justify-end mb-10 gap-4">
<Button variant="outline">
<Link to="/admin/products" className="flex items-center gap-2">
<X className="w-4 h-4" />
Cancelar
</Link>
</Button>
<Button>
<SaveAll className="w-4 h-4" />
Guardar cambios
</Button>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main Form */}
<div className="lg:col-span-2 space-y-6">
{/* Basic Information */}
<div className="bg-white rounded-xl shadow-lg border border-slate-200 p-6">
<h2 className="text-xl font-semibold text-slate-800 mb-6">
Información del producto
</h2>
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Título del producto
</label>
<input
type="text"
value={product.title}
onChange={(e) => handleInputChange('title', e.target.value)}
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
placeholder="Título del producto"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Precio ($)
</label>
<input
type="number"
value={product.price}
onChange={(e) =>
handleInputChange('price', parseFloat(e.target.value))
}
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
placeholder="Precio del producto"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Stock del producto
</label>
<input
type="number"
value={product.stock}
onChange={(e) =>
handleInputChange('stock', parseInt(e.target.value))
}
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
placeholder="Stock del producto"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Slug del producto
</label>
<input
type="text"
value={product.slug}
onChange={(e) => handleInputChange('slug', e.target.value)}
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
placeholder="Slug del producto"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Género del producto
</label>
<select
value={product.gender}
onChange={(e) =>
handleInputChange('gender', e.target.value)
}
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
>
<option value="men">Hombre</option>
<option value="women">Mujer</option>
<option value="unisex">Unisex</option>
<option value="kids">Niño</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Descripción del producto
</label>
<textarea
value={product.description}
onChange={(e) =>
handleInputChange('description', e.target.value)
}
rows={5}
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200 resize-none"
placeholder="Descripción del producto"
/>
</div>
</div>
</div>
{/* Sizes */}
<div className="bg-white rounded-xl shadow-lg border border-slate-200 p-6">
<h2 className="text-xl font-semibold text-slate-800 mb-6">
Tallas disponibles
</h2>
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{product.sizes.map((size) => (
<span
key={size}
className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800 border border-blue-200"
>
{size}
<button
onClick={() => removeSize(size)}
className="ml-2 text-blue-600 hover:text-blue-800 transition-colors duration-200"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
<div className="flex flex-wrap gap-2 pt-2 border-t border-slate-200">
<span className="text-sm text-slate-600 mr-2">
Añadir tallas:
</span>
{availableSizes.map((size) => (
<button
key={size}
onClick={() => addSize(size)}
disabled={product.sizes.includes(size)}
className={`px-3 py-1 rounded-full text-sm font-medium transition-all duration-200 ${
product.sizes.includes(size)
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-slate-200 text-slate-700 hover:bg-slate-300 cursor-pointer'
}`}
>
{size}
</button>
))}
</div>
</div>
</div>
{/* Tags */}
<div className="bg-white rounded-xl shadow-lg border border-slate-200 p-6">
<h2 className="text-xl font-semibold text-slate-800 mb-6">
Etiquetas
</h2>
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{product.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800 border border-green-200"
>
<Tag className="h-3 w-3 mr-1" />
{tag}
<button
onClick={() => removeTag(tag)}
className="ml-2 text-green-600 hover:text-green-800 transition-colors duration-200"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addTag()}
placeholder="Añadir nueva etiqueta..."
className="flex-1 px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
/>
<Button onClick={addTag} className="px-4 py-2rounded-lg ">
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Product Images */}
<div className="bg-white rounded-xl shadow-lg border border-slate-200 p-6">
<h2 className="text-xl font-semibold text-slate-800 mb-6">
Imágenes del producto
</h2>
{/* Drag & Drop Zone */}
<div
className={`relative border-2 border-dashed rounded-lg p-6 text-center transition-all duration-200 ${
dragActive
? 'border-blue-400 bg-blue-50'
: 'border-slate-300 hover:border-slate-400'
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<input
type="file"
multiple
accept="image/*"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
onChange={handleFileChange}
/>
<div className="space-y-4">
<Upload className="mx-auto h-12 w-12 text-slate-400" />
<div>
<p className="text-lg font-medium text-slate-700">
Arrastra las imágenes aquí
</p>
<p className="text-sm text-slate-500">
o haz clic para buscar
</p>
</div>
<p className="text-xs text-slate-400">
PNG, JPG, WebP hasta 10MB cada una
</p>
</div>
</div>
{/* Current Images */}
<div className="mt-6 space-y-3">
<h3 className="text-sm font-medium text-slate-700">
Imágenes actuales
</h3>
<div className="grid grid-cols-2 gap-3">
{product.images.map((image, index) => (
<div key={index} className="relative group">
<div className="aspect-square bg-slate-100 rounded-lg border border-slate-200 flex items-center justify-center">
<img
src={image}
alt="Product"
className="w-full h-full object-cover rounded-lg"
/>
</div>
<button className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<X className="h-3 w-3" />
</button>
<p className="mt-1 text-xs text-slate-600 truncate">
{image}
</p>
</div>
))}
</div>
</div>
</div>
{/* Product Status */}
<div className="bg-white rounded-xl shadow-lg border border-slate-200 p-6">
<h2 className="text-xl font-semibold text-slate-800 mb-6">
Estado del producto
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<span className="text-sm font-medium text-slate-700">
Estado
</span>
<span className="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">
Activo
</span>
</div>
<div className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<span className="text-sm font-medium text-slate-700">
Inventario
</span>
<span
className={`px-2 py-1 text-xs font-medium rounded-full ${
product.stock > 5
? 'bg-green-100 text-green-800'
: product.stock > 0
? 'bg-yellow-100 text-yellow-800'
: 'bg-red-100 text-red-800'
}`}
>
{product.stock > 5
? 'En stock'
: product.stock > 0
? 'Bajo stock'
: 'Sin stock'}
</span>
</div>
<div className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<span className="text-sm font-medium text-slate-700">
Imágenes
</span>
<span className="text-sm text-slate-600">
{product.images.length} imágenes
</span>
</div>
<div className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<span className="text-sm font-medium text-slate-700">
Tallas disponibles
</span>
<span className="text-sm text-slate-600">
{product.sizes.length} tallas
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</>
);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment