Skip to main content
Products are managed at /admin/products. The page uses a thin orchestrator pattern — AdminProducts delegates all data logic to the useAdminProducts hook, and rendering to lego components: ProductsHeader, ProductsFilter, ProductsTable, and ProductEditorDrawer.

Routes

RouteComponentPurpose
/admin/productsAdminProductsPaginated list, filtering, bulk actions
/admin/products/newAdminProductFormCreate a new product
/admin/products/:idAdminProductFormEdit an existing product

Product Data Shape

export interface ProductFormData {
    name: string;
    slug: string;
    description: string;
    short_description: string;
    price: number;
    compare_at_price: number | null;
    stock: number;
    sku: string;
    section: 'vape' | '420';
    category_id: string;
    tags: string[];
    status: ProductStatus;          // 'active' | 'legacy' | 'discontinued' | 'coming_soon'
    images: string[];               // Array of public URLs
    cover_image: string | null;
    is_featured: boolean;
    is_featured_until: string | null;     // ISO timestamp — flag expires at this time
    is_new: boolean;
    is_new_until: string | null;
    is_bestseller: boolean;
    is_bestseller_until: string | null;
    is_active: boolean;
    specs: Record<string, string>;        // Structured specifications
    badges: string[];                     // Display badges e.g. ['Sale', 'Limited']
    ai_sales_note: string | null;         // AI-generated selling note
    ai_is_featured: boolean;              // AI recommendation to feature
    ai_exclude: boolean;                  // Exclude from AI recommendations
    variants?: ProductVariant[];
}

Product Status Values

type ProductStatus = 'active' | 'legacy' | 'discontinued' | 'coming_soon';
ValueDescription
activeAvailable for purchase, visible in the storefront
legacyStill listed but de-emphasized; older product line
discontinuedNo longer sold; hidden from active catalog
coming_soonAnnounced but not yet available to purchase

CRUD Functions

getAllProducts

export async function getAllProducts(): Promise<ProductFormData[]>
Fetches all products ordered by created_at descending. Returns all fields including specs, badges, ai_sales_note, ai_is_featured, and ai_exclude.

createProduct

export async function createProduct(
    product: ProductFormData
): Promise<{ id: string; name: string; slug: string; price: number; stock: number; sku: string; section: string; category_id: string; is_active: boolean }>

updateProduct

export async function updateProduct(
    id: string,
    product: Partial<ProductFormData>
): Promise<{ id: string; name: string; slug: string; price: number; stock: number; sku: string; section: string; category_id: string; is_active: boolean }>

deleteProduct

export async function deleteProduct(id: string): Promise<void>
deleteProduct is a soft delete — it sets is_active = false on the record rather than removing it from the database. This preserves order history referencing the product.

toggleProductFlag

export async function toggleProductFlag(
    id: string,
    flag: 'is_featured' | 'is_new' | 'is_bestseller' | 'is_active',
    value: boolean
): Promise<void>
Used by the ProductsTable row toggle controls to flip a single boolean flag without a full form submission.

Time-Expiring Product Flags

The is_featured, is_new, and is_bestseller flags each have a companion timestamp column:
FlagExpiry ColumnBehavior
is_featuredis_featured_untilProduct stops appearing in featured sections after this timestamp
is_newis_new_until”New” badge disappears after this timestamp
is_bestselleris_bestseller_until”Bestseller” badge disappears after this timestamp
Set the expiry column to a future ISO timestamp when enabling a flag. Set it to null for a permanent flag.
// Example: Feature a product for 30 days
const thirtyDaysOut = new Date();
thirtyDaysOut.setDate(thirtyDaysOut.getDate() + 30);

await updateProduct(productId, {
    is_featured: true,
    is_featured_until: thirtyDaysOut.toISOString(),
});

Image Upload

Images are uploaded to the product-images Supabase Storage bucket under the raw/ prefix:
export async function uploadProductImage(file: File): Promise<string> {
    const fileExt = file.name.split('.').pop();
    const fileName = `${Date.now()}_${Math.random().toString(36).substring(2, 9)}.${fileExt}`;
    const filePath = `raw/${fileName}`;

    const { error } = await supabase.storage
        .from('product-images')
        .upload(filePath, file, {
            cacheControl: '3600',
            upsert: false
        });

    if (error) throw error;

    const { data: publicUrlData } = supabase.storage
        .from('product-images')
        .getPublicUrl(filePath);

    return publicUrlData.publicUrl;
}
Filenames are generated with Date.now() plus a random suffix to prevent collisions. The returned public URL is stored in the images array or cover_image field of the product.

Section and Category Assignment

Every product belongs to one of two catalog sections:
type Section = 'vape' | '420';
  • vape — Mods, Atomizadores, Líquidos, Coils, Accesorios Vape
  • 420 — Vaporizers, Fumables, Comestibles, Concentrados, Tópicos, Accesorios 420
The category_id field references a row in the categories table. Categories are hierarchical — a category may have a parent_id pointing to a parent category. The section of the category must match the product’s section.

Bulk Operations

The ProductsTable supports multi-select. When rows are selected, a floating action bar appears with:
  • Activar — Bulk is_active = true
  • Desactivar — Bulk is_active = false
  • Magic Sync (IA) — Bulk AI copy generation via product-intelligence edge function
Bulk updates use bulkUpdateProducts:
export async function bulkUpdateProducts(
    updates: { id: string; updates: Partial<ProductFormData> }[]
): Promise<void>
This runs all updates concurrently with Promise.all. If any single update fails, the entire operation throws.

AI Copy Generation

export async function generateProductCopy(
    name: string,
    currentDesc?: string
): Promise<{ description: string; short_description: string; tags: string[] }>
Calls the product-intelligence Supabase Edge Function with action: 'generate_copy'. Returns AI-generated description, short_description, and suggested tags based on the product name and optional existing description.

Product Editor Drawer

The ProductEditorDrawer component renders as a slide-in panel. It is used for both create (no id passed) and edit (existing product passed) workflows — keeping the list view in context while editing. Duplicate a product by cloning its data without the id:
const handleDuplicate = (product: Product) => {
    const clone: Product = {
        ...product,
        id: '',
        name: `${product.name} (Copia)`,
        slug: `${product.slug}-copia`,
    };
    setEditingProduct(clone);
    setIsEditorOpen(true);
};

CSV Export

The products list supports export to CSV via a button in ProductsHeader:
const handleExportCSV = () => {
    const headers = ['Nombre', 'SKU', 'Seccion', 'Precio', 'Stock', 'Activo'];
    // Creates productos_vsm_YYYY-MM-DD.csv
};

Filtering

The ProductsFilter component exposes:
  • Search — filters by product name (client-side)
  • Sectionvape, 420, or all
  • Show Inactive — toggle to include soft-deleted / inactive products
  • Quick Filter — Featured, New, Bestseller, Low Stock shortcuts

Build docs developers (and LLMs) love