Libro de Recetas

import React, { useEffect, useMemo, useRef, useState } from «react»;/** * Libro de Recetas – Cosmética Natural * ———————————— * Archivo tipo React (no HTML suelto). Corrige error «Unexpected token (1:0)» * causado por pegar un documento HTML completo en un archivo JS/TSX. * * ✅ Funciona 100% offline (localStorage) * ✅ Crear / duplicar / eliminar recetas * ✅ Buscar y filtrar por categoría / etiquetas * ✅ Ingredientes con unidades: g, ml, %, gotas, cucharadas, cucharaditas * ✅ Escalado directo (% → peso) y escalado inverso (peso → %) * ✅ Exportar/Importar JSON + Imprimir * 🚫 Sin cálculo de precios (retirado por solicitud) * ✅ Incluye panel de pruebas (DevTests) */// ———- Utilidades ———- const LS_KEY = «cosmi_recipes_v3»; const uid = () => Math.random().toString(36).slice(2, 9); const nowISO = () => new Date().toISOString(); const prettyDate = (iso) => new Date(iso).toLocaleString();const defaultRecipe = () => ({ id: uid(), name: «Nueva receta», category: «General», tags: [«prototipo»], batchSize: 100, batchUnit: «g», // g | ml | unidades shelfLifeMonths: 12, lot: «», inciNotes: «», nsoCode: «», steps: `1) Preparar materiales\n2) Mezclar\n3) Envasar`, // Template literal bien cerrado ✔ notes: «», ingredients: [ { id: uid(), name: «Agua destilada», qty: 70, unit: «%», note: «Fase A» }, { id: uid(), name: «Glicerina», qty: 10, unit: «%», note: «Hidratante» }, { id: uid(), name: «Conservante», qty: 0.8, unit: «%», note: «Según ficha» }, ], createdAt: nowISO(), updatedAt: nowISO(), });const useLocalStore = (key, initialValue) => { const [state, setState] = useState(() => { try { const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : initialValue; } catch (e) { console.warn(«Error leyendo localStorage», e); return initialValue; } }); useEffect(() => { try { localStorage.setItem(key, JSON.stringify(state)); } catch (e) { console.warn(«Error escribiendo localStorage», e); } }, [key, state]); return [state, setState]; };// ———- Helpers de fórmula ———- const allowedUnits = [«g», «ml», «%», «gotas», «cucharadas», «cucharaditas»];function totalPercentOf(ingredients) { return (ingredients || []) .filter((i) => i.unit === «%») .reduce((s, i) => s + Number(i.qty || 0), 0); }function convertPercentToAbsolute(pct, batch) { return (Number(pct || 0) / 100) * Number(batch || 0); }function convertAbsoluteToPercent(abs, batch) { const b = Number(batch || 0); if (!b) return 0; return (Number(abs || 0) / b) * 100; }// ———- Pequeño módulo de tests (visual) ———- function runDevTests() { const tests = [];// 1) defaultRecipe.steps está bien cerrado y contiene ‘Envasar’ try { const d = defaultRecipe(); tests.push({ name: «defaultRecipe.steps es string multilinea», pass: typeof d.steps === «string» && /Envasar/.test(d.steps), expected: «string con ‘Envasar'», got: String(d.steps).slice(0, 50) + «…», }); } catch (e) { tests.push({ name: «defaultRecipe no lanza error», pass: false, expected: «sin error», got: String(e) }); }// 2) % → peso (10% de 100 g = 10 g) try { const got = convertPercentToAbsolute(10, 100); tests.push({ name: «%→peso básico», pass: Math.abs(got – 10) < 1e-9, expected: 10, got }); } catch (e) { tests.push({ name: "%→peso (error)", pass: false, expected: 10, got: String(e) }); }// 3) peso → % (20 g en 200 g = 10%) try { const got = convertAbsoluteToPercent(20, 200); tests.push({ name: "peso→% básico", pass: Math.abs(got - 10) < 1e-9, expected: 10, got }); } catch (e) { tests.push({ name: "peso→% (error)", pass: false, expected: 10, got: String(e) }); }// 4) Suma de % de una fórmula try { const ingredients = [ { qty: 70, unit: "%" }, { qty: 10, unit: "%" }, { qty: 0.8, unit: "%" }, ]; const got = totalPercentOf(ingredients); tests.push({ name: "Suma de %", pass: Math.abs(got - 80.8) < 1e-9, expected: 80.8, got }); } catch (e) { tests.push({ name: "Suma de % (error)", pass: false, expected: 80.8, got: String(e) }); }// 5) No rompe con unidades no-% (g/ml) try { const ingredients = [ { qty: 10, unit: "g" }, { qty: 5, unit: "ml" }, ]; const got = totalPercentOf(ingredients); tests.push({ name: "Total % ignora g/ml", pass: Math.abs(got - 0) < 1e-9, expected: 0, got }); } catch (e) { tests.push({ name: "Total % ignora g/ml (error)", pass: false, expected: 0, got: String(e) }); }return tests; }function DevTestsPanel() { const [open, setOpen] = useState(false); const tests = useMemo(() => (open ? runDevTests() : []), [open]); const passed = tests.filter((t) => t.pass).length; return (
{open && (
Pruebas: {passed}/{tests.length} OK
    {tests.map((t, idx) => (
  • {t.pass ? «✔» : «✖»}
    {t.name}
    {!t.pass && (
    esperado: {String(t.expected)} • obtenido: {String(t.got)}
    )}
  • ))}
)}
); }// ———- Componentes UI ———- const Button = ({ children, className = «», …props }) => ( );const Input = ({ className = «», …props }) => ( );const Select = ({ className = «», children, …props }) => ( );const TextArea = ({ className = «», …props }) => (