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.

Revisions

  1. Klerith created this gist Jul 22, 2025.
    459 changes: 459 additions & 0 deletions ProductPage.tsx
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,459 @@
    // 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>
    </>
    );
    };