From a5bd514d67374812f2f138bf9a7b9d45bcc0d970 Mon Sep 17 00:00:00 2001 From: Juan Salazar Date: Tue, 16 Jun 2026 17:34:52 -0400 Subject: [PATCH 1/6] feat(storage): migrate image uploads to Supabase Replace local filesystem storage with Supabase Storage for plant scan images to improve scalability and accessibility. Added utility scripts for bucket verification. --- server.ts | 25 ++++++++++++++----------- test-bucket.js | 16 ++++++++++++++++ test-upload.js | 13 +++++++++++++ 3 files changed, 43 insertions(+), 11 deletions(-) create mode 100644 test-bucket.js create mode 100644 test-upload.js diff --git a/server.ts b/server.ts index 02ad040..67cb215 100644 --- a/server.ts +++ b/server.ts @@ -749,7 +749,7 @@ async function startServer() { app.post("/api/scan-plant", async (req, res) => { const { base64Image, mimeType, isPresetSeed, presetIndex, targetElement } = req.body; - // Guardar imagen en src/Imagenes de inmediato si se provee + // Guardar imagen en Supabase Storage de inmediato si se provee let savedImagePath = ""; if (base64Image && /^data:image\/\w+;base64,/.test(base64Image)) { try { @@ -771,19 +771,22 @@ async function startServer() { if (ext === "jpeg") ext = "jpg"; else if (ext === "svg+xml") ext = "svg"; - const dirPath = path.join(process.cwd(), "src", "Imagenes"); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } - const fileName = `scan-${Date.now()}.${ext}`; - const filePath = path.join(dirPath, fileName); - fs.writeFileSync(filePath, buffer); - console.log(`📸 Imagen de escaneo guardada exitosamente en: ${filePath}`); - savedImagePath = `/src/Imagenes/${fileName}`; + // Subir a Supabase Storage: bucket 'imagenes' + const { data, error } = await supabase.storage.from("imagenes").upload(fileName, buffer, { + contentType: mimeType || `image/${ext}` + }); + + if (error) { + console.error("Error al subir imagen a Supabase:", error); + } else { + const { data: publicUrlData } = supabase.storage.from("imagenes").getPublicUrl(fileName); + savedImagePath = publicUrlData.publicUrl; + console.log(`📸 Imagen de escaneo guardada exitosamente en Supabase: ${savedImagePath}`); + } } catch (err) { - console.error("Error al guardar la imagen en src/Imagenes:", err); + console.error("Error procesando o subiendo la imagen:", err); } } diff --git a/test-bucket.js b/test-bucket.js new file mode 100644 index 0000000..9160fde --- /dev/null +++ b/test-bucket.js @@ -0,0 +1,16 @@ +import { createClient } from '@supabase/supabase-js'; + +const rawSupabaseUrl = "https://klaompnbmjufvhjkeeno.supabase.co"; +const supabaseUrl = rawSupabaseUrl.replace(/\/rest\/v1\/?$/, ''); +const supabaseAnonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtsYW9tcG5ibWp1ZnZoamtlZW5vIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODE1NjY5ODEsImV4cCI6MjA5NzE0Mjk4MX0.udKgeFZLsVzXvSU0oqR0F3_J7EDCA1g7MxF00l8LEEc"; + +const supabase = createClient(supabaseUrl, supabaseAnonKey); + +async function check() { + const bucketsToTest = ['imagenes', 'Imagenes', 'images', 'crops']; + for (const name of bucketsToTest) { + const { data, error } = await supabase.storage.getBucket(name); + console.log(`Bucket ${name}:`, data ? "EXISTS" : error?.message); + } +} +check(); diff --git a/test-upload.js b/test-upload.js new file mode 100644 index 0000000..a9c6ef8 --- /dev/null +++ b/test-upload.js @@ -0,0 +1,13 @@ +import { createClient } from '@supabase/supabase-js'; + +const rawSupabaseUrl = "https://klaompnbmjufvhjkeeno.supabase.co"; +const supabaseUrl = rawSupabaseUrl.replace(/\/rest\/v1\/?$/, ''); +const supabaseAnonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtsYW9tcG5ibWp1ZnZoamtlZW5vIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODE1NjY5ODEsImV4cCI6MjA5NzE0Mjk4MX0.udKgeFZLsVzXvSU0oqR0F3_J7EDCA1g7MxF00l8LEEc"; + +const supabase = createClient(supabaseUrl, supabaseAnonKey); + +async function check() { + const { data, error } = await supabase.storage.from('imagenes').upload('test.txt', 'hello world'); + console.log(`Upload to imagenes:`, data, error); +} +check(); From 9dcc7cbef15ca052d4f6c407d163e695a7015529 Mon Sep 17 00:00:00 2001 From: Juan Salazar Date: Tue, 16 Jun 2026 17:45:20 -0400 Subject: [PATCH 2/6] refactor: migrate to Supabase and clean up server Remove local file-based storage and redundant static routes in favor of Supabase. Update RLS policies in schema.sql to ensure proper table access. --- schema.sql | 25 ++++++++++++++++++++ server.ts | 67 ++++++++++++++++-------------------------------------- 2 files changed, 44 insertions(+), 48 deletions(-) diff --git a/schema.sql b/schema.sql index 434ef04..3ca4f5c 100644 --- a/schema.sql +++ b/schema.sql @@ -51,15 +51,40 @@ ALTER TABLE public.ledger ENABLE ROW LEVEL SECURITY; ALTER TABLE public.store_metrics ENABLE ROW LEVEL SECURITY; -- 5. Create policies to allow all actions for anonymous users (for development) +DROP POLICY IF EXISTS "Allow anonymous read crops" ON public.crops; CREATE POLICY "Allow anonymous read crops" ON public.crops FOR SELECT USING (true); +DROP POLICY IF EXISTS "Allow anonymous insert crops" ON public.crops; CREATE POLICY "Allow anonymous insert crops" ON public.crops FOR INSERT WITH CHECK (true); +DROP POLICY IF EXISTS "Allow anonymous update crops" ON public.crops; CREATE POLICY "Allow anonymous update crops" ON public.crops FOR UPDATE USING (true); +DROP POLICY IF EXISTS "Allow anonymous delete crops" ON public.crops; CREATE POLICY "Allow anonymous delete crops" ON public.crops FOR DELETE USING (true); +DROP POLICY IF EXISTS "Allow anonymous read ledger" ON public.ledger; CREATE POLICY "Allow anonymous read ledger" ON public.ledger FOR SELECT USING (true); +DROP POLICY IF EXISTS "Allow anonymous insert ledger" ON public.ledger; CREATE POLICY "Allow anonymous insert ledger" ON public.ledger FOR INSERT WITH CHECK (true); +DROP POLICY IF EXISTS "Allow anonymous delete ledger" ON public.ledger; CREATE POLICY "Allow anonymous delete ledger" ON public.ledger FOR DELETE USING (true); +DROP POLICY IF EXISTS "Allow anonymous read metrics" ON public.store_metrics; CREATE POLICY "Allow anonymous read metrics" ON public.store_metrics FOR SELECT USING (true); +DROP POLICY IF EXISTS "Allow anonymous insert metrics" ON public.store_metrics; CREATE POLICY "Allow anonymous insert metrics" ON public.store_metrics FOR INSERT WITH CHECK (true); +DROP POLICY IF EXISTS "Allow anonymous update metrics" ON public.store_metrics; CREATE POLICY "Allow anonymous update metrics" ON public.store_metrics FOR UPDATE USING (true); + +-- 6. Storage Bucket setup for 'imagenes' +-- Important: You must run this in the Supabase SQL Editor +INSERT INTO storage.buckets (id, name, public) VALUES ('imagenes', 'imagenes', true) ON CONFLICT DO NOTHING; + +-- Storage RLS Policies +DROP POLICY IF EXISTS "Allow public read imagenes" ON storage.objects; +CREATE POLICY "Allow public read imagenes" ON storage.objects FOR SELECT USING (bucket_id = 'imagenes'); +DROP POLICY IF EXISTS "Allow public insert imagenes" ON storage.objects; +CREATE POLICY "Allow public insert imagenes" ON storage.objects FOR INSERT WITH CHECK (bucket_id = 'imagenes'); +DROP POLICY IF EXISTS "Allow public update imagenes" ON storage.objects; +CREATE POLICY "Allow public update imagenes" ON storage.objects FOR UPDATE USING (bucket_id = 'imagenes'); +DROP POLICY IF EXISTS "Allow public delete imagenes" ON storage.objects; +CREATE POLICY "Allow public delete imagenes" ON storage.objects FOR DELETE USING (bucket_id = 'imagenes'); + diff --git a/server.ts b/server.ts index 67cb215..0a46254 100644 --- a/server.ts +++ b/server.ts @@ -505,9 +505,10 @@ function saveVolume(volume: number) { } } -let activeCrops: ServerCrop[] = loadCrops(); -let paymentLedger: ServerLedgerLog[] = loadLedger(); -let mockVolumenSalesUsd = loadVolume(); +// Removing local variables +let activeCrops: ServerCrop[] = []; +let paymentLedger: ServerLedgerLog[] = []; +let mockVolumenSalesUsd = 0; export const app = express(); @@ -516,9 +517,8 @@ async function startServer() { app.use(express.json({ limit: "15mb" })); app.use(express.urlencoded({ extended: true, limit: "15mb" })); - app.use("/src/Imagenes", express.static(path.join(process.cwd(), "src", "Imagenes"))); - app.use("/src/imagenes", express.static(path.join(process.cwd(), "src", "imagenes"))); - + // Remove express.static for imagenes since we use Supabase now + // API Endpoints app.get("/api/crops", async (req, res) => { try { @@ -527,7 +527,7 @@ async function startServer() { res.json(data || []); } catch (error: any) { console.error("Supabase /api/crops GET error:", error.message || error); - res.json(activeCrops); + res.status(500).json({ error: "Fallo al conectar con la base de datos (Supabase)." }); } }); @@ -579,11 +579,7 @@ async function startServer() { res.status(201).json(data?.[0] || newCrop); } catch (error: any) { console.error("Supabase /api/crops POST error:", JSON.stringify(error, null, 2), error.message); - if (!activeCrops.find(c => c.id === newCrop.id)) { - activeCrops.push(newCrop); - saveCrops(activeCrops); - } - res.status(201).json(newCrop); + res.status(500).json({ error: "Error al guardar el cultivo en base de datos. Verifica RLS y tablas." }); } }); @@ -597,18 +593,11 @@ async function startServer() { if (data && data.length > 0) { res.json(data[0]); } else { - res.status(404).json({ error: "Cultivo no encontrado" }); + res.status(404).json({ error: "Cultivo no encontrado en DB" }); } } catch (error: any) { console.error("Supabase /api/crops PUT error:", error); - const idx = activeCrops.findIndex(c => c.id === id); - if (idx !== -1) { - activeCrops[idx] = { ...activeCrops[idx], ...req.body }; - saveCrops(activeCrops); - res.json(activeCrops[idx]); - } else { - res.status(404).json({ error: "Cultivo no encontrado localmente" }); - } + res.status(500).json({ error: "Error actualizando el cultivo en DB." }); } }); @@ -620,9 +609,7 @@ async function startServer() { res.json({ success: true }); } catch (error: any) { console.error("Supabase /api/crops DELETE error:", error); - activeCrops = activeCrops.filter(c => c.id !== id); - saveCrops(activeCrops); - res.json({ success: true }); + res.status(500).json({ error: "Error borrando el cultivo en DB." }); } }); @@ -636,14 +623,11 @@ async function startServer() { res.json({ ledger: ledgerData || [], - totalSalesUsd: volumeData?.totalSalesUsd || mockVolumenSalesUsd || 0, + totalSalesUsd: volumeData?.totalSalesUsd || 0, }); } catch (error: any) { console.error("Supabase /api/ledger GET error:", error); - res.json({ - ledger: paymentLedger, - totalSalesUsd: mockVolumenSalesUsd - }); + res.status(500).json({ error: "Error obteniendo el ledger." }); } }); @@ -670,24 +654,16 @@ async function startServer() { const { error } = await supabase.from('ledger').insert([newLog as any]); if (error) throw error; - const newVol = Number((mockVolumenSalesUsd + usdValue).toFixed(2)); - mockVolumenSalesUsd = newVol; + const { data: volumeData } = await supabase.from('store_metrics').select('totalSalesUsd').eq('id', 'main').single(); + const currentVol = volumeData?.totalSalesUsd || 0; + const newVol = Number((currentVol + usdValue).toFixed(2)); + await supabase.from('store_metrics').upsert([{ id: 'main', totalSalesUsd: newVol }]); res.status(201).json({ log: newLog, totalSalesUsd: newVol }); } catch (error: any) { console.error("Supabase /api/ledger POST error:", error); - - if (!paymentLedger.find(l => l.id === newLog.id)) { - paymentLedger.unshift(newLog as ServerLedgerLog); - saveLedger(paymentLedger); - - const newVol = Number((mockVolumenSalesUsd + usdValue).toFixed(2)); - mockVolumenSalesUsd = newVol; - saveVolume(newVol); - } - - res.status(201).json({ log: newLog, totalSalesUsd: mockVolumenSalesUsd }); + res.status(500).json({ error: "Error guardando el pago." }); } }); @@ -695,16 +671,11 @@ async function startServer() { try { await supabase.from('ledger').delete().neq('id', 'clear_all'); await supabase.from('store_metrics').upsert([{ id: 'main', totalSalesUsd: 0 }]); - mockVolumenSalesUsd = 0; res.json({ success: true, totalSalesUsd: 0.00 }); } catch (error: any) { console.error("Supabase /api/ledger DELETE error:", error); - paymentLedger = []; - saveLedger(paymentLedger); - mockVolumenSalesUsd = 0; - saveVolume(0); - res.json({ success: true, totalSalesUsd: 0.00 }); + res.status(500).json({ error: "Error borrando el ledger." }); } }); From 9f1163ff2720bc27bc469657a29c5a6f09380edc Mon Sep 17 00:00:00 2001 From: Juan Salazar Date: Tue, 16 Jun 2026 17:54:01 -0400 Subject: [PATCH 3/6] refactor: optimize Gemini retry logic and cleanup - Reduce max retries and update model names to mitigate Vercel timeout errors. - Remove unused directory path variables in server configuration. - Add utility script for testing Supabase bucket connectivity. --- server.ts | 20 +++++++------------- test-buckets.js | 12 ++++++++++++ 2 files changed, 19 insertions(+), 13 deletions(-) create mode 100644 test-buckets.js diff --git a/server.ts b/server.ts index 0a46254..7347d17 100644 --- a/server.ts +++ b/server.ts @@ -1,6 +1,5 @@ import express from "express"; import path from "path"; -import { fileURLToPath } from "url"; import { GoogleGenAI, Type } from "@google/genai"; import dotenv from "dotenv"; import { createServer as createViteServer } from "vite"; @@ -9,9 +8,6 @@ import { createClient } from "@supabase/supabase-js"; dotenv.config(); -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - // Supabase configuration const rawSupabaseUrl = "https://klaompnbmjufvhjkeeno.supabase.co"; const supabaseUrl = rawSupabaseUrl.replace(/\/rest\/v1\/?$/, ''); @@ -681,12 +677,11 @@ async function startServer() { // Gemini Scan Helper with exponential retries and fallback model async function generateBotanicalContentWithRetry(client: any, imgPart: any, textPart: any, schema: any) { - const maxAttempts = 3; + const maxAttempts = 2; // Reduced to avoid vercel timeout let lastError: any = null; for (let attempt = 1; attempt <= maxAttempts; attempt++) { - // Alternate models: Attempt 1 uses gemini-3.5-flash. If busy, try gemini-3.1-flash-lite immediately - const model = attempt === 1 ? "gemini-3.5-flash" : "gemini-3.1-flash-lite"; + const model = attempt === 1 ? "gemini-2.5-flash" : "gemini-1.5-flash"; console.log(`🤖 [Botanical Analysis] Attempt ${attempt}/${maxAttempts} using model: ${model}`); try { const response = await client.models.generateContent({ @@ -704,16 +699,15 @@ async function startServer() { throw new Error("Respuesta vacia"); } catch (err: any) { lastError = err; - console.log(`[Botanical Analysis] Model ${model} returned status: API_BUSY_OR_UNAVAILABLE`); - // If we still have attempts, sleep with exponential backoff + console.log(`[Botanical Analysis] Model ${model} returned error:`, err?.message || err); + // If we still have attempts, sleep short to avoid vercel limits if (attempt < maxAttempts) { - const sleepMs = attempt * 1500; - console.log(`[Botanical Analysis] Sleeping ${sleepMs}ms before next model attempt...`); - await new Promise((resolve) => setTimeout(resolve, sleepMs)); + await new Promise((resolve) => setTimeout(resolve, 500)); } } } - throw lastError || new Error("Se rebasaron todos los reintentos"); + console.error("Gemini failed after retries:", lastError); + return null; } // Scan Plant Endpoint diff --git a/test-buckets.js b/test-buckets.js new file mode 100644 index 0000000..769d6e7 --- /dev/null +++ b/test-buckets.js @@ -0,0 +1,12 @@ +import { createClient } from '@supabase/supabase-js'; + +const supabase = createClient( + "https://klaompnbmjufvhjkeeno.supabase.co", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtsYW9tcG5ibWp1ZnZoamtlZW5vIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODE1NjY5ODEsImV4cCI6MjA5NzE0Mjk4MX0.udKgeFZLsVzXvSU0oqR0F3_J7EDCA1g7MxF00l8LEEc" +); + +async function check() { + const { data, error } = await supabase.storage.listBuckets(); + console.log("Buckets:", data?.map(b => b.name), error); +} +check(); From 43fbd922ed4aae453cf16b4b4a629b83170c2ce8 Mon Sep 17 00:00:00 2001 From: Maikol Castellano <56364360+soymaikoldev@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:58:13 -0400 Subject: [PATCH 4/6] Refactor server.ts to load initial data --- server.ts | 71 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 51 insertions(+), 20 deletions(-) diff --git a/server.ts b/server.ts index 3a4f959..910c29d 100644 --- a/server.ts +++ b/server.ts @@ -558,10 +558,9 @@ function saveVolume(volume: number) { } } -// Removing local variables -let activeCrops: ServerCrop[] = []; -let paymentLedger: ServerLedgerLog[] = []; -let mockVolumenSalesUsd = 0; +let activeCrops: ServerCrop[] = loadCrops(); +let paymentLedger: ServerLedgerLog[] = loadLedger(); +let mockVolumenSalesUsd = loadVolume(); export const app = express(); @@ -599,7 +598,7 @@ async function startServer() { res.json(data || []); } catch (error: any) { console.error("Supabase /api/crops GET error:", error.message || error); - res.status(500).json({ error: "Fallo al conectar con la base de datos (Supabase)." }); + res.json(activeCrops); } }); @@ -659,7 +658,11 @@ async function startServer() { res.status(201).json(data?.[0] || newCrop); } catch (error: any) { console.error("Supabase /api/crops POST error:", JSON.stringify(error, null, 2), error.message); - res.status(500).json({ error: "Error al guardar el cultivo en base de datos. Verifica RLS y tablas." }); + if (!activeCrops.find(c => c.id === newCrop.id)) { + activeCrops.push(newCrop); + saveCrops(activeCrops); + } + res.status(201).json(newCrop); } }); @@ -677,11 +680,18 @@ async function startServer() { if (data && data.length > 0) { res.json(data[0]); } else { - res.status(404).json({ error: "Cultivo no encontrado en DB" }); + res.status(404).json({ error: "Cultivo no encontrado" }); } } catch (error: any) { console.error("Supabase /api/crops PUT error:", error); - res.status(500).json({ error: "Error actualizando el cultivo en DB." }); + const idx = activeCrops.findIndex(c => c.id === id); + if (idx !== -1) { + activeCrops[idx] = { ...activeCrops[idx], ...req.body }; + saveCrops(activeCrops); + res.json(activeCrops[idx]); + } else { + res.status(404).json({ error: "Cultivo no encontrado localmente" }); + } } }); @@ -697,7 +707,9 @@ async function startServer() { res.json({ success: true }); } catch (error: any) { console.error("Supabase /api/crops DELETE error:", error); - res.status(500).json({ error: "Error borrando el cultivo en DB." }); + activeCrops = activeCrops.filter(c => c.id !== id); + saveCrops(activeCrops); + res.json({ success: true }); } }); @@ -719,11 +731,14 @@ async function startServer() { res.json({ ledger: ledgerData || [], - totalSalesUsd: volumeData?.totalSalesUsd || 0, + totalSalesUsd: volumeData?.totalSalesUsd || mockVolumenSalesUsd || 0, }); } catch (error: any) { console.error("Supabase /api/ledger GET error:", error); - res.status(500).json({ error: "Error obteniendo el ledger." }); + res.json({ + ledger: paymentLedger, + totalSalesUsd: mockVolumenSalesUsd + }); } }); @@ -765,7 +780,17 @@ async function startServer() { res.status(201).json({ log: newLog, totalSalesUsd: newVol }); } catch (error: any) { console.error("Supabase /api/ledger POST error:", error); - res.status(500).json({ error: "Error guardando el pago." }); + + if (!paymentLedger.find(l => l.id === newLog.id)) { + paymentLedger.unshift(newLog as ServerLedgerLog); + saveLedger(paymentLedger); + + const newVol = Number((mockVolumenSalesUsd + usdValue).toFixed(2)); + mockVolumenSalesUsd = newVol; + saveVolume(newVol); + } + + res.status(201).json({ log: newLog, totalSalesUsd: mockVolumenSalesUsd }); } }); @@ -786,17 +811,22 @@ async function startServer() { res.json({ success: true, totalSalesUsd: 0.00 }); } catch (error: any) { console.error("Supabase /api/ledger DELETE error:", error); - res.status(500).json({ error: "Error borrando el ledger." }); + paymentLedger = []; + saveLedger(paymentLedger); + mockVolumenSalesUsd = 0; + saveVolume(0); + res.json({ success: true, totalSalesUsd: 0.00 }); } }); // Gemini Scan Helper with exponential retries and fallback model async function generateBotanicalContentWithRetry(client: any, imgPart: any, textPart: any, schema: any) { - const maxAttempts = 2; // Reduced to avoid vercel timeout + const maxAttempts = 3; let lastError: any = null; for (let attempt = 1; attempt <= maxAttempts; attempt++) { - const model = attempt === 1 ? "gemini-2.5-flash" : "gemini-1.5-flash"; + // Alternate models: Attempt 1 uses gemini-3.5-flash. If busy, try gemini-3.1-flash-lite immediately + const model = attempt === 1 ? "gemini-3.5-flash" : "gemini-3.1-flash-lite"; console.log(`🤖 [Botanical Analysis] Attempt ${attempt}/${maxAttempts} using model: ${model}`); try { const response = await client.models.generateContent({ @@ -814,15 +844,16 @@ async function startServer() { throw new Error("Respuesta vacia"); } catch (err: any) { lastError = err; - console.log(`[Botanical Analysis] Model ${model} returned error:`, err?.message || err); - // If we still have attempts, sleep short to avoid vercel limits + console.log(`[Botanical Analysis] Model ${model} returned status: API_BUSY_OR_UNAVAILABLE`); + // If we still have attempts, sleep with exponential backoff if (attempt < maxAttempts) { - await new Promise((resolve) => setTimeout(resolve, 500)); + const sleepMs = attempt * 1500; + console.log(`[Botanical Analysis] Sleeping ${sleepMs}ms before next model attempt...`); + await new Promise((resolve) => setTimeout(resolve, sleepMs)); } } } - console.error("Gemini failed after retries:", lastError); - return null; + throw lastError || new Error("Se rebasaron todos los reintentos"); } // Scan Plant Endpoint From 8506fddacd793fa59f7e8c908760228e6e3da703 Mon Sep 17 00:00:00 2001 From: Maikol Castellano <56364360+soymaikoldev@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:08:33 -0400 Subject: [PATCH 5/6] Keep Vercel API app inside api bundle --- .env.example | 1 + api/app.ts | 1067 +++++++++++++++++++++++++++++++ api/index.ts | 2 +- server.ts | 976 +--------------------------- src/components/PlantScanner.tsx | 35 +- 5 files changed, 1091 insertions(+), 990 deletions(-) create mode 100644 api/app.ts diff --git a/.env.example b/.env.example index 5092938..96e1ff2 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,4 @@ APP_URL="MY_APP_URL" # Supabase Credentials VITE_SUPABASE_URL="https://klaompnbmjufvhjkeeno.supabase.co" VITE_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtsYW9tcG5ibWp1ZnZoamtlZW5vIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODE1NjY5ODEsImV4cCI6MjA5NzE0Mjk4MX0.udKgeFZLsVzXvSU0oqR0F3_J7EDCA1g7MxF00l8LEEc" +SUPABASE_STORAGE_BUCKET="imagenes" diff --git a/api/app.ts b/api/app.ts new file mode 100644 index 0000000..910c29d --- /dev/null +++ b/api/app.ts @@ -0,0 +1,1067 @@ +import express from "express"; +import path from "path"; +import { GoogleGenAI, Type } from "@google/genai"; +import dotenv from "dotenv"; +import { createServer as createViteServer } from "vite"; +import fs from "fs"; +import { createClient } from "@supabase/supabase-js"; + +dotenv.config(); + +// Supabase configuration +const rawSupabaseUrl = "https://klaompnbmjufvhjkeeno.supabase.co"; +const supabaseUrl = rawSupabaseUrl.replace(/\/rest\/v1\/?$/, ''); +const supabaseAnonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtsYW9tcG5ibWp1ZnZoamtlZW5vIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODE1NjY5ODEsImV4cCI6MjA5NzE0Mjk4MX0.udKgeFZLsVzXvSU0oqR0F3_J7EDCA1g7MxF00l8LEEc"; +const supabase = createClient(supabaseUrl, supabaseAnonKey); +const SUPABASE_QUERY_TIMEOUT_MS = 3000; +const PLANT_IMAGES_BUCKET = process.env.SUPABASE_STORAGE_BUCKET || "imagenes"; + +const normalizeImageExtension = (mimeType?: string) => { + const rawExt = mimeType?.split("/")?.[1]?.toLowerCase() || "jpg"; + if (rawExt === "jpeg") return "jpg"; + if (rawExt === "svg+xml") return "svg"; + return rawExt.replace(/[^a-z0-9]/g, "") || "jpg"; +}; + +const uploadScanImageToSupabase = async ( + base64Image: string, + mimeType?: string +): Promise => { + const parts = base64Image.split(";base64,"); + const cleanBase = parts.length > 1 ? parts[1] : base64Image; + const buffer = Buffer.from(cleanBase, "base64"); + const contentType = mimeType || base64Image.match(/^data:([^;]+);base64,/i)?.[1] || "image/jpeg"; + const ext = normalizeImageExtension(contentType); + const objectPath = `scans/scan-${Date.now()}-${Math.random().toString(36).slice(2, 10)}.${ext}`; + + const { error } = await supabase.storage + .from(PLANT_IMAGES_BUCKET) + .upload(objectPath, buffer, { + contentType, + upsert: false, + }); + + if (error) { + throw error; + } + + const { data } = supabase.storage + .from(PLANT_IMAGES_BUCKET) + .getPublicUrl(objectPath); + + return data.publicUrl; +}; + +const withTimeout = async ( + promise: Promise, + timeoutMs: number, + errorMessage: string +): Promise => { + let timeout: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(errorMessage)), timeoutMs); + }); + + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +}; + +// Initialize Gemini Client Lazily/Safely +let aiClient: GoogleGenAI | null = null; +const getGeminiClient = (): GoogleGenAI | null => { + const apiKey = process.env.GEMINI_API_KEY; + if (!apiKey || apiKey === "MY_GEMINI_API_KEY") { + console.warn("⚠️ Advertencia: GEMINI_API_KEY no está configurada o usa el marcador por defecto."); + return null; + } + if (!aiClient) { + try { + aiClient = new GoogleGenAI({ + apiKey: apiKey, + httpOptions: { + headers: { + "User-Agent": "aistudio-build", + }, + }, + }); + console.log("✅ Cliente Gemini configurado exitosamente."); + } catch (e) { + console.error("❌ Error al instanciar el cliente Gemini:", e); + } + } + return aiClient; +}; + +// Mock standard database of crop results to fallback in case of errors / no API key +const PRESETS_BOTANICAL: Array<{ + name: string; + scientificName: string; + origin: string; + uses: string; + description: string; + difficulty: "Fácil" | "Moderado" | "Difícil"; + suggestedPriceSol: number; + suggestedPriceUsdc: number; + suggestedPriceUsdt: number; + category: "Hortalizas" | "Medicinales" | "Frutas" | "Hierbas" | "Otro"; + watering: string; + sunlight: string; + idealSowingSeason: string; + harvestTimeDays: string; + soilType: string; + phRecommended: string; + companionPlants: string; + pestPrevention: string; + detectedElement?: string; + image: string; +}> = [ + { + name: "Rábano Fast-Grow", + scientificName: "Raphanus sativus", + origin: "Eurasia", + detectedElement: "Raíz", + uses: "Alimenticio: Consumo en ensaladas, aporta textura crujiente y toque picante. Alto en vitamina C y fibra.", + description: "Pequeña raíz globosa de color rojo intenso con pulpa blanca, crujiente y refrescante.", + difficulty: "Fácil", + suggestedPriceSol: 0.015, + suggestedPriceUsdc: 0.75, + suggestedPriceUsdt: 0.75, + category: "Hortalizas", + watering: "Riego regular y uniforme para evitar que la raíz se agriete o se ponga demasiado picante.", + sunlight: "Sol pleno o semisombra.", + idealSowingSeason: "Primavera, Otoño y finales de Verano.", + harvestTimeDays: "21 a 30 días", + soilType: "Suelo suelto, ligero, bien drenado y rico en materia orgánica.", + phRecommended: "6.0 a 7.0.", + companionPlants: "Espinaca, lechuga y guisantes. Ayuda a dispersar plagas comunes.", + pestPrevention: "Proteger con malla anti-insectos en las primeras etapas y vigilar el escarabajo pulga.", + image: "https://images.unsplash.com/photo-1590005354167-6da97870c913?auto=format&fit=crop&q=80&w=300" + }, + { + name: "Menta Piperita", + scientificName: "Mentha x piperita", + origin: "Europa", + uses: "Medicinal: Alivia espasmos, digestiones difíciles e infusión relajante. Culinario: Aromatizante de licores y postres.", + description: "Planta herbácea perenne muy aromática, de tallos cuadrangulares rojizos y hojas dentadas con intenso olor a mentol.", + difficulty: "Fácil", + suggestedPriceSol: 0.015, + suggestedPriceUsdc: 0.75, + suggestedPriceUsdt: 0.75, + category: "Medicinales", + watering: "Riego abundante y regular. Prefiere suelos húmedos de forma continua pero con desague óptimo.", + sunlight: "Sombra parcial o semisombra. Prefiere luz tamizada indirecta.", + idealSowingSeason: "Principios de Primavera u Otoño (es altamente invasiva, preferir cultivo en macetas separadas).", + harvestTimeDays: "60 a 70 días tras la siembra.", + soilType: "Suelo arcilloso o suelto pero muy rico en materia orgánica con alta capacidad de retención de humedad.", + phRecommended: "6.5 a 7.0.", + companionPlants: "Repollo, coliflor y lechuga. Repele plagas de orugas. ¡No plantar cerca de manzanilla o perejil!", + pestPrevention: "Podar a ras de suelo en Otoño para un resurgir vigoroso en Primavera. Controlar caracoles manualmente.", + detectedElement: "Hojas", + image: "https://images.unsplash.com/photo-1608686207856-001b95cf60ca?auto=format&fit=crop&q=80&w=300" + }, + { + name: "Frutilla Silvestre", + scientificName: "Fragaria vesca", + origin: "Eurasia", + uses: "Alimenticio: Consumo fresco, mermeladas y helados. Rica en antioxidantes, ácido fólico y vitamina C.", + description: "Planta rastrera perenne que produce estolones, hojas trifoliadas dentadas y pequeños frutos rojos muy fragantes y dulces.", + difficulty: "Moderado", + suggestedPriceSol: 0.06, + suggestedPriceUsdc: 2.50, + suggestedPriceUsdt: 2.50, + category: "Frutas", + watering: "Moderado y constante. El método de goteo es excelente para proteger la corona de la planta de pudriciones.", + sunlight: "Pleno sol para máxima fructificación y sabor dulce, pero tolera algo de semisombra.", + idealSowingSeason: "A finales de Otoño o principios de Primavera.", + harvestTimeDays: "90 a 120 días tras la plantación.", + soilType: "Suelo arenoso rico en humus, bien acolchado con paja seca para evitar que las frutillas toquen la tierra.", + phRecommended: "5.8 a 6.2.", + companionPlants: "Espinacas, cebollas y borraja. Evitar plantar cerca de otras solanáceas o brassicas.", + pestPrevention: "Abonar con compost enriquecido en potasio. Vigilar la aparición de pulgones rociando jabón potásico.", + detectedElement: "Frutas", + image: "https://images.unsplash.com/photo-1464965911861-746a04b4bca6?auto=format&fit=crop&q=80&w=300" + }, + { + name: "Helecho Espada Sol", + scientificName: "Nephrolepis exaltata", + origin: "Zonas tropicales de América y Asia", + uses: "Ornamental: Purifica el aire interior capturando formaldehído. Excelente para colgar en canastos.", + description: "Espectacular planta perenne de frondas arqueadas y plumosas, que aporta un follaje verde y tupido sumamente decorativo.", + difficulty: "Fácil", + suggestedPriceSol: 0.03, + suggestedPriceUsdc: 1.20, + suggestedPriceUsdt: 1.20, + category: "Otro", + watering: "Frecuente para mantener el sustrato constantemente húmedo pero sin encharcar la maceta.", + sunlight: "Luz indirecta brillante o semisombra. Evitar el sol directo que quema sus delicadas frondas.", + idealSowingSeason: "Primavera u Otoño húmedo.", + harvestTimeDays: "Crecimiento constante todo el año", + soilType: "Sustrato a base de turba, poroso, rico en nutrientes y con excelente drenaje de agua.", + phRecommended: "5.5 a 6.0.", + companionPlants: "Orquídeas, potos y otras plantas que disfrutan de alta humedad ambiental.", + pestPrevention: "Mantener alta humedad pulverizando agua regularmente para ahuyentar a la araña roja.", + detectedElement: "Plantas", + image: "https://images.unsplash.com/photo-1545241047-6083a3684587?auto=format&fit=crop&q=80&w=300" + }, + { + name: "Pimiento Dulce", + scientificName: "Capsicum annuum", + origin: "Mesoamérica", + uses: "Culinario: Consumo fresco, asado o frito. Fuente excelente de vitaminas A y C esenciales.", + description: "Fruto hueco de paredes carnosas y jugosas que cambia de verde a colores brillantes rojos, amarillos o naranjas al madurar.", + difficulty: "Moderado", + suggestedPriceSol: 0.04, + suggestedPriceUsdc: 1.50, + suggestedPriceUsdt: 1.50, + category: "Hortalizas", + watering: "Humedad regular. Evitar estrés hídrico durante la floración y el desarrollo del fruto.", + sunlight: "Pleno sol constante y temperaturas cálidas para un óptimo cuajado de frutos.", + idealSowingSeason: "Finales de Invierno o principios de Primavera.", + harvestTimeDays: "70 a 90 días", + soilType: "Suelo fértil, profundo, rico en materia orgánica y con excelente desague.", + phRecommended: "6.0 a 6.8.", + companionPlants: "Albahaca, tomate, cebollas y cilantro de huerto.", + pestPrevention: "Uso de trampas cromáticas amarillas y pulverización preventiva con jabón potásico para pulgones.", + detectedElement: "Frutos", + image: "https://images.unsplash.com/photo-1563861826100-9cb868fdcd1e?auto=format&fit=crop&q=80&w=300" + }, + { + name: "Espinaca Clorofílica", + scientificName: "Spinacia oleracea", + origin: "Persia antigua", + uses: "Alimenticio: Gran aporte de hierro, ácido fólico y clorofila depurativa. Se consume fresca o cocida.", + description: "Hojas carnosas de color verde oscuro brillante dispuestas en roseta, ricas en clorofila y fitonutrientes saludables.", + difficulty: "Fácil", + suggestedPriceSol: 0.02, + suggestedPriceUsdc: 1.00, + suggestedPriceUsdt: 1.00, + category: "Hortalizas", + watering: "Riego regular y moderado, manteniendo la tierra uniformemente húmeda pero nunca pesada.", + sunlight: "Semisombra o sol parcial. La luz solar excesiva puede acelerar la producción prematura de semillas.", + idealSowingSeason: "Otoño y Primavera temprana para disfrutar del clima fresco.", + harvestTimeDays: "40 a 50 días", + soilType: "Suelos pesados o francos, muy ricos en nitrógeno orgánico.", + phRecommended: "6.5 a 7.5.", + companionPlants: "Frutillas, habas, guisantes y repollo.", + pestPrevention: "Remover malezas manualmente y controlar orugas con tratamientos con Bacillus thuringiensis de ser necesario.", + detectedElement: "Clorofila", + image: "https://images.unsplash.com/photo-1576045057995-568f588f82fb?auto=format&fit=crop&q=80&w=300" + }, + { + name: "Apio de Sacramento", + scientificName: "Apium graveolens", + origin: "Zonas mediterráneas", + uses: "Culinario: Cocción en caldos, sopas o consumo de tallos crujientes en ensaladas y jugos desintoxicantes.", + description: "Planta con gruesos tallos acanalados y fibrosos que forman una corona compacta de color verde pálido.", + difficulty: "Difícil", + suggestedPriceSol: 0.035, + suggestedPriceUsdc: 1.40, + suggestedPriceUsdt: 1.40, + category: "Hortalizas", + watering: "Exigente en riego continuo. Necesita humedad constante y un sustrato rico en nutrientes.", + sunlight: "Semisombra o pleno sol con protección frente a vientos secos.", + idealSowingSeason: "Primavera para cosecha en Otoño.", + harvestTimeDays: "120 a 150 días", + soilType: "Suelo de huerta pesado o arcilloso, muy fértil y retenedor de agua.", + phRecommended: "6.0 a 7.0.", + companionPlants: "Cebolla, ajo, coliflor y tomates.", + pestPrevention: "Abonar intensivamente y pulverizar decocciones de cola de caballo para prevenir hongos.", + detectedElement: "Tallo", + image: "https://images.unsplash.com/photo-1610970881699-44a5587cabec?auto=format&fit=crop&q=80&w=300" + }, + { + name: "Manzanilla de la Reina", + scientificName: "Matricaria chamomilla", + origin: "Europa y Asia templada", + uses: "Infusión medicinal: Aliviador gástrico y sedante natural. Antiinflamatorio ocular.", + description: "Planta herbácea con pequeñas flores similares a margaritas, que emiten una fragancia dulce y relajante.", + difficulty: "Fácil", + suggestedPriceSol: 0.02, + suggestedPriceUsdc: 0.90, + suggestedPriceUsdt: 0.90, + category: "Medicinales", + watering: "Moderado. Soporta muy bien periodos cortos de sequía una vez establecida.", + sunlight: "Pleno sol para maximizar la concentración de aceites esenciales curativos.", + idealSowingSeason: "Otoño o Primavera directa a suelo.", + harvestTimeDays: "60 a 80 días", + soilType: "Suelo liviano, arenoso y no demasiado fértil.", + phRecommended: "6.0 a 7.2.", + companionPlants: "Cebollas, coles y trigo. Mejora el sabor de vecinas aromáticas.", + pestPrevention: "Evitar suelos pesados que propicien pudrición de raíz. Pulverizar agua con ajo si surge pulgón.", + detectedElement: "Flor", + image: "https://images.unsplash.com/photo-1588145293284-cd9d282e4e13?auto=format&fit=crop&q=80&w=300" + }, + { + name: "Girasol de Oro", + scientificName: "Helianthus annuus", + origin: "Norteamérica", + uses: "Culinario: Extracción de aceite de alta cocina y snack saludable de semillas crujientes de girasol.", + description: "Hermosa e imponente inflorescencia amarilla gigante que sigue la trayectoria solar diaria, albergando cientos de ricas semillas.", + difficulty: "Fácil", + suggestedPriceSol: 0.025, + suggestedPriceUsdc: 1.10, + suggestedPriceUsdt: 1.10, + category: "Hierbas", + watering: "Moderado pero profundo. Tolera sequía moderada gracias a su raíz pivotante larga.", + sunlight: "Sol directo absoluto (mínimo de 6 a 8 horas diarias prescritas).", + idealSowingSeason: "Mediados de Primavera, tras desaparecer las heladas.", + harvestTimeDays: "80 a 110 días", + soilType: "Suelo suelto y profundo que permita la libre extensión de su raíz.", + phRecommended: "6.0 a 7.5.", + companionPlants: "Maíz, pepinos y calabazas montantes.", + pestPrevention: "Proteger las flores maduras de las aves con mallas finas si se desea conservar las semillas intactas.", + detectedElement: "Semilla", + image: "https://images.unsplash.com/photo-1595855759920-86582396756a?auto=format&fit=crop&q=80&w=300" + }, + { + name: "Sangre de Dragón", + scientificName: "Croton lechleri", + origin: "Amazonía americana", + uses: "Medicinal: Cicatrizante ultrapotente de heridas y protector celular cutáneo ante patógenos.", + description: "Árbol tropical cuya corteza, al ser raspada o cortada, secreta una savia rojiza espesa rica en taspina.", + difficulty: "Difícil", + suggestedPriceSol: 0.09, + suggestedPriceUsdc: 3.50, + suggestedPriceUsdt: 3.50, + category: "Medicinales", + watering: "Riego espaciado simulando las abundantes lluvias de selva.", + sunlight: "Cálido y húmedo con sol directo filtrado o tamizado.", + idealSowingSeason: "Estación lluviosa tropical.", + harvestTimeDays: "Varios años para el desarrollo óptimo de su tronco.", + soilType: "Suelo de bosque húmedo con abundante humus ácido.", + phRecommended: "5.0 a 6.0.", + companionPlants: "Helechos tropicales, bromelias y cacao de monte.", + pestPrevention: "Tratar plagas fúngicas de hojas tiernas con macerado purificante de ajo y jengibre.", + detectedElement: "Savia", + image: "https://images.unsplash.com/photo-1502082553048-f009c37129b9?auto=format&fit=crop&q=80&w=300" + }, + { + name: "Hiedra de Pared", + scientificName: "Hedera helix", + origin: "Europa, Asia y África", + uses: "Funcional: Cubrimiento térmico estético de muros e investigación botánica de estomas activos en hojas perennes.", + description: "Planta trepadora perenne con hojas verdes brillantes coriáceas muy estudiada para revelar estomas microscópicos.", + difficulty: "Fácil", + suggestedPriceSol: 0.02, + suggestedPriceUsdc: 0.85, + suggestedPriceUsdt: 0.85, + category: "Otro", + watering: "Moderado. Dejar secar ligeramente la capa superior de tierra antes del siguiente riego.", + sunlight: "Prefiere sombra o luz tamizada.", + idealSowingSeason: "Primavera u Otoño en climas templados.", + harvestTimeDays: "Crecimiento constante rápido.", + soilType: "Cualquier suelo bien drenado, tolera suelos pobres en nutrientes.", + phRecommended: "6.0 a 7.5.", + companionPlants: "Cualquier planta de sotobosque y helechos.", + pestPrevention: "Humedecer el follaje para prevenir ácaros y cochinillas en épocas de calor extremo.", + detectedElement: "Estomas", + image: "https://images.unsplash.com/photo-1508739773434-c26b3d09e071?auto=format&fit=crop&q=80&w=300" + } +]; + +// Memory state to support persistence over developer server session +interface ServerCrop { + id: string; + name: string; + scientificName: string; + origin: string; + uses: string; + description: string; + difficulty: "Fácil" | "Moderado" | "Difícil"; + image: string; + priceSol: number; + priceUsdc: number; + priceUsdt: number; + stock: number; + isForSale: boolean; + category: "Hortalizas" | "Medicinales" | "Frutas" | "Hierbas" | "Otro"; + watering?: string; + sunlight?: string; + idealSowingSeason?: string; + harvestTimeDays?: string; + soilType?: string; + phRecommended?: string; + companionPlants?: string; + pestPrevention?: string; + detectedElement?: string; +} + +interface ServerLedgerLog { + id: string; + timestamp: string; + cropName: string; + quantity: number; + amount: number; + currency: "SOL" | "USDC" | "USDT"; + signature: string; + status: "EXITOSO" | "PENDIENTE" | "FALLIDO"; +} + +const CROPS_FILE = path.join(process.cwd(), "crops_data.json"); +const LEDGER_FILE = path.join(process.cwd(), "ledger_data.json"); +const VOLUME_FILE = path.join(process.cwd(), "volume_data.json"); + +// Helper function to resolve high-quality Unsplash image by plant/crop name +function getFallbackImageByPlantName(name: string): string { + const lowercase = (name || "").toLowerCase(); + + if (lowercase.includes("cafeto") || lowercase.includes("café") || lowercase.includes("coffee")) { + return "https://images.unsplash.com/photo-1514432324607-a09d9b4aefdd?auto=format&fit=crop&q=80&w=300"; + } + if (lowercase.includes("caña") || lowercase.includes("sugarcane")) { + return "https://images.unsplash.com/photo-1543257580-7269da773bf5?auto=format&fit=crop&q=80&w=300"; + } + if (lowercase.includes("plátano") || lowercase.includes("platano") || lowercase.includes("banano") || lowercase.includes("banana") || lowercase.includes("guineo")) { + return "https://images.unsplash.com/photo-1571771894821-ce9b6c11b08e?auto=format&fit=crop&q=80&w=300"; + } + if (lowercase.includes("aguacate") || lowercase.includes("avocado") || lowercase.includes("palta")) { + return "https://images.unsplash.com/photo-1523049673857-eb18f1d7b578?auto=format&fit=crop&q=80&w=300"; + } + if (lowercase.includes("naranja") || lowercase.includes("orange") || lowercase.includes("cítrico") || lowercase.includes("citrico")) { + return "https://images.unsplash.com/photo-1547514701-42782101795e?auto=format&fit=crop&q=80&w=300"; + } + if (lowercase.includes("rabano") || lowercase.includes("radish")) { + return "https://images.unsplash.com/photo-1590005354167-6da97870c913?auto=format&fit=crop&q=80&w=300"; + } + if (lowercase.includes("menta") || lowercase.includes("mint") || lowercase.includes("piperita")) { + return "https://images.unsplash.com/photo-1608686207856-001b95cf60ca?auto=format&fit=crop&q=80&w=300"; + } + if (lowercase.includes("frutilla") || lowercase.includes("fresa") || lowercase.includes("strawberry")) { + return "https://images.unsplash.com/photo-1464965911861-746a04b4bca6?auto=format&fit=crop&q=80&w=300"; + } + + return `https://images.unsplash.com/photo-1466692476868-aef1dfb1e735?auto=format&fit=crop&q=80&w=300`; +} + +function loadCrops(): ServerCrop[] { + try { + if (fs.existsSync(CROPS_FILE)) { + const data = fs.readFileSync(CROPS_FILE, "utf-8"); + const list: ServerCrop[] = JSON.parse(data); + let updated = false; + const sanitized = list.map(c => { + // Purge base64 / excessively large image strings that cause QuotaExceededError + if (c.image && (c.image.startsWith("data:") || c.image.length > 1000)) { + c.image = getFallbackImageByPlantName(c.name); + updated = true; + } + return c; + }); + if (updated) { + saveCrops(sanitized); + } + return sanitized; + } + } catch (e) { + console.error("Error al cargar crops_data.json:", e); + } + return [ + { + id: "crop-2", + name: "Rábano Fast-Grow", + detectedElement: "Raíz", + scientificName: "Raphanus sativus", + origin: "Eurasia", + uses: "Alimenticio: Consumo en ensaladas, aporta textura crujiente y toque picante. Alto en vitamina C.", + description: "Pequeña raíz globosa de color rojo intenso con pulpa blanca, crujiente y refrescante.", + difficulty: "Fácil", + image: "https://images.unsplash.com/photo-1590005354167-6da97870c913?auto=format&fit=crop&q=80&w=300", + priceSol: 0.015, + priceUsdc: 0.75, + priceUsdt: 0.75, + stock: 5, + isForSale: true, + category: "Hortalizas", + watering: "Riego regular y uniforme para evitar que la raíz se agriete o se ponga demasiado picante.", + sunlight: "Sol pleno o semisombra.", + idealSowingSeason: "Primavera, Otoño.", + harvestTimeDays: "21 a 30 días.", + soilType: "Suelo suelto, ligero, bien drenado.", + phRecommended: "6.0 a 7.0.", + companionPlants: "Espinaca, lechuga y guisantes.", + pestPrevention: "Proteger con malla anti-insectos." + } + ]; +} + +function saveCrops(crops: ServerCrop[]) { + try { + fs.writeFileSync(CROPS_FILE, JSON.stringify(crops, null, 2), "utf-8"); + } catch (e) { + console.error("Error al guardar crops_data.json:", e); + } +} + +function loadLedger(): ServerLedgerLog[] { + try { + if (fs.existsSync(LEDGER_FILE)) { + const data = fs.readFileSync(LEDGER_FILE, "utf-8"); + return JSON.parse(data); + } + } catch (e) { + console.error("Error al cargar ledger_data.json:", e); + } + return [ + { + id: "tx-1", + timestamp: new Date(Date.now() - 30 * 60000).toISOString(), + cropName: "Rábano Fast-Grow", + quantity: 1, + amount: 0.75, + currency: "USDC", + signature: "5R7P37v6y8X9qZd2B1cK3eHgFdSjKa8s9dF2gH1jK3l7s9z2x3c4v5b6n7m8", + status: "EXITOSO", + }, + { + id: "tx-2", + timestamp: new Date(Date.now() - 15 * 60000).toISOString(), + cropName: "Rábano Fast-Grow", + quantity: 1, + amount: 0.75, + currency: "USDT", + signature: "5R2W9qZd2B1cK3eHgFdSjKa8s9dF2gH1jK3l7s9z2x3c4v5b6n7m8tx3k2l19", + status: "EXITOSO", + } + ]; +} + +function saveLedger(ledger: ServerLedgerLog[]) { + try { + fs.writeFileSync(LEDGER_FILE, JSON.stringify(ledger, null, 2), "utf-8"); + } catch (e) { + console.error("Error al guardar ledger_data.json:", e); + } +} + +function loadVolume(): number { + try { + if (fs.existsSync(VOLUME_FILE)) { + const data = fs.readFileSync(VOLUME_FILE, "utf-8"); + return Number(data) || 28.62; + } + } catch (e) { + console.error("Error al cargar volume_data.json:", e); + } + return 28.62; +} + +function saveVolume(volume: number) { + try { + fs.writeFileSync(VOLUME_FILE, String(volume), "utf-8"); + } catch (e) { + console.error("Error al guardar volume_data.json:", e); + } +} + +let activeCrops: ServerCrop[] = loadCrops(); +let paymentLedger: ServerLedgerLog[] = loadLedger(); +let mockVolumenSalesUsd = loadVolume(); + +export const app = express(); + +async function startServer() { + const REAL_PORT = 3000; + + app.use(express.json({ limit: "15mb" })); + app.use(express.urlencoded({ extended: true, limit: "15mb" })); + app.use((err: any, _req: any, res: any, next: any) => { + if (!err) { + return next(); + } + + console.error("Error procesando el cuerpo de la petición:", err); + const status = err.type === "entity.too.large" ? 413 : 400; + return res.status(status).json({ + success: false, + error: status === 413 + ? "La imagen es demasiado grande para procesarla. Intenta con una foto más liviana." + : "No se pudo interpretar la petición de análisis.", + }); + }); + app.use("/src/Imagenes", express.static(path.join(process.cwd(), "src", "Imagenes"))); + app.use("/src/imagenes", express.static(path.join(process.cwd(), "src", "imagenes"))); + + // API Endpoints + app.get("/api/crops", async (req, res) => { + try { + const { data, error } = await withTimeout( + Promise.resolve(supabase.from('crops').select('*')), + SUPABASE_QUERY_TIMEOUT_MS, + 'Timeout consultando cultivos en Supabase.' + ); + if (error) throw error; + res.json(data || []); + } catch (error: any) { + console.error("Supabase /api/crops GET error:", error.message || error); + res.json(activeCrops); + } + }); + + app.post("/api/crops", async (req, res) => { + const { + id, name, scientificName, origin, uses, description, difficulty, + priceSol, priceUsdc, priceUsdt, stock, isForSale, category, image, + watering, sunlight, idealSowingSeason, harvestTimeDays, soilType, + phRecommended, companionPlants, pestPrevention, detectedElement + } = req.body; + + const newCrop = { + id: id || `crop-${Date.now()}`, + name: name || "Cultivo Desconocido", + scientificName: scientificName || "Incognita", + origin: origin || "Desconocido", + uses: uses || "No especificado", + description: description || "No disponible", + difficulty: (difficulty || "Fácil"), + image: image || "", + priceSol: Number(priceSol) || 0.01, + priceUsdc: Number(priceUsdc) || 0.5, + priceUsdt: Number(priceUsdt) || 0.5, + stock: Number(stock) || 5, + isForSale: isForSale !== undefined ? isForSale : false, + category: category || "Otro", + watering: watering || "", + sunlight: sunlight || "", + idealSowingSeason: idealSowingSeason || "", + harvestTimeDays: harvestTimeDays || "", + soilType: soilType || "", + phRecommended: phRecommended || "", + companionPlants: companionPlants || "", + pestPrevention: pestPrevention || "", + detectedElement: detectedElement || "Plantas" + }; + + try { + // Check if exists + const { data: existing } = await withTimeout( + Promise.resolve(supabase.from('crops').select('id').eq('id', newCrop.id).maybeSingle()), + SUPABASE_QUERY_TIMEOUT_MS, + 'Timeout verificando cultivo existente en Supabase.' + ); + + if (existing) { + return res.json(newCrop); + } + + const { data, error } = await withTimeout( + Promise.resolve(supabase.from('crops').insert([newCrop]).select()), + SUPABASE_QUERY_TIMEOUT_MS, + 'Timeout guardando cultivo en Supabase.' + ); + if (error) throw error; + + res.status(201).json(data?.[0] || newCrop); + } catch (error: any) { + console.error("Supabase /api/crops POST error:", JSON.stringify(error, null, 2), error.message); + if (!activeCrops.find(c => c.id === newCrop.id)) { + activeCrops.push(newCrop); + saveCrops(activeCrops); + } + res.status(201).json(newCrop); + } + }); + + app.put("/api/crops/:id", async (req, res) => { + const { id } = req.params; + try { + const { data, error } = await withTimeout( + Promise.resolve(supabase.from('crops').update(req.body).eq('id', id).select()), + SUPABASE_QUERY_TIMEOUT_MS, + 'Timeout actualizando cultivo en Supabase.' + ); + + if (error) throw error; + + if (data && data.length > 0) { + res.json(data[0]); + } else { + res.status(404).json({ error: "Cultivo no encontrado" }); + } + } catch (error: any) { + console.error("Supabase /api/crops PUT error:", error); + const idx = activeCrops.findIndex(c => c.id === id); + if (idx !== -1) { + activeCrops[idx] = { ...activeCrops[idx], ...req.body }; + saveCrops(activeCrops); + res.json(activeCrops[idx]); + } else { + res.status(404).json({ error: "Cultivo no encontrado localmente" }); + } + } + }); + + app.delete("/api/crops/:id", async (req, res) => { + const { id } = req.params; + try { + const { error } = await withTimeout( + Promise.resolve(supabase.from('crops').delete().eq('id', id)), + SUPABASE_QUERY_TIMEOUT_MS, + 'Timeout eliminando cultivo en Supabase.' + ); + if (error) throw error; + res.json({ success: true }); + } catch (error: any) { + console.error("Supabase /api/crops DELETE error:", error); + activeCrops = activeCrops.filter(c => c.id !== id); + saveCrops(activeCrops); + res.json({ success: true }); + } + }); + + // Payment Ledger Endpoints + app.get("/api/ledger", async (req, res) => { + try { + const { data: ledgerData, error: ledgerError } = await withTimeout( + Promise.resolve(supabase.from('ledger').select('*').order('timestamp', { ascending: false })), + SUPABASE_QUERY_TIMEOUT_MS, + 'Timeout consultando ledger en Supabase.' + ); + if (ledgerError) throw ledgerError; + + const { data: volumeData } = await withTimeout( + Promise.resolve(supabase.from('store_metrics').select('totalSalesUsd').eq('id', 'main').maybeSingle()), + SUPABASE_QUERY_TIMEOUT_MS, + 'Timeout consultando métricas en Supabase.' + ); + + res.json({ + ledger: ledgerData || [], + totalSalesUsd: volumeData?.totalSalesUsd || mockVolumenSalesUsd || 0, + }); + } catch (error: any) { + console.error("Supabase /api/ledger GET error:", error); + res.json({ + ledger: paymentLedger, + totalSalesUsd: mockVolumenSalesUsd + }); + } + }); + + app.post("/api/ledger", async (req, res) => { + const { cropName, quantity, amount, currency, signature, timestamp, id } = req.body; + + const newLog = { + id: id || `tx-${Date.now()}`, + timestamp: timestamp || new Date().toISOString(), + cropName: cropName || "Compra de Cultivo", + quantity: Number(quantity) || 1, + amount: Number(amount) || 0, + currency: currency || "SOL", + signature: signature || Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15), + status: "EXITOSO" as const + }; + + let usdValue = newLog.amount; + if (newLog.currency === "SOL") { + usdValue = newLog.amount * 150.0; + } + + try { + const { error } = await withTimeout( + Promise.resolve(supabase.from('ledger').insert([newLog as any])), + SUPABASE_QUERY_TIMEOUT_MS, + 'Timeout guardando ledger en Supabase.' + ); + if (error) throw error; + + const newVol = Number((mockVolumenSalesUsd + usdValue).toFixed(2)); + mockVolumenSalesUsd = newVol; + await withTimeout( + Promise.resolve(supabase.from('store_metrics').upsert([{ id: 'main', totalSalesUsd: newVol }])), + SUPABASE_QUERY_TIMEOUT_MS, + 'Timeout actualizando métricas en Supabase.' + ); + + res.status(201).json({ log: newLog, totalSalesUsd: newVol }); + } catch (error: any) { + console.error("Supabase /api/ledger POST error:", error); + + if (!paymentLedger.find(l => l.id === newLog.id)) { + paymentLedger.unshift(newLog as ServerLedgerLog); + saveLedger(paymentLedger); + + const newVol = Number((mockVolumenSalesUsd + usdValue).toFixed(2)); + mockVolumenSalesUsd = newVol; + saveVolume(newVol); + } + + res.status(201).json({ log: newLog, totalSalesUsd: mockVolumenSalesUsd }); + } + }); + + app.delete("/api/ledger", async (req, res) => { + try { + await withTimeout( + Promise.resolve(supabase.from('ledger').delete().neq('id', 'clear_all')), + SUPABASE_QUERY_TIMEOUT_MS, + 'Timeout limpiando ledger en Supabase.' + ); + await withTimeout( + Promise.resolve(supabase.from('store_metrics').upsert([{ id: 'main', totalSalesUsd: 0 }])), + SUPABASE_QUERY_TIMEOUT_MS, + 'Timeout reiniciando métricas en Supabase.' + ); + mockVolumenSalesUsd = 0; + + res.json({ success: true, totalSalesUsd: 0.00 }); + } catch (error: any) { + console.error("Supabase /api/ledger DELETE error:", error); + paymentLedger = []; + saveLedger(paymentLedger); + mockVolumenSalesUsd = 0; + saveVolume(0); + res.json({ success: true, totalSalesUsd: 0.00 }); + } + }); + + // Gemini Scan Helper with exponential retries and fallback model + async function generateBotanicalContentWithRetry(client: any, imgPart: any, textPart: any, schema: any) { + const maxAttempts = 3; + let lastError: any = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + // Alternate models: Attempt 1 uses gemini-3.5-flash. If busy, try gemini-3.1-flash-lite immediately + const model = attempt === 1 ? "gemini-3.5-flash" : "gemini-3.1-flash-lite"; + console.log(`🤖 [Botanical Analysis] Attempt ${attempt}/${maxAttempts} using model: ${model}`); + try { + const response = await client.models.generateContent({ + model: model, + contents: { parts: [imgPart, textPart] }, + config: { + systemInstruction: "Eres un asesor agrónomo especializado experto en botánica. Devuelve exclusivamente el esquema JSON solicitado sin textos aclaratorios, markdown fuera del JSON ni introducciones.", + responseMimeType: "application/json", + responseSchema: schema, + }, + }); + if (response && response.text) { + return response; + } + throw new Error("Respuesta vacia"); + } catch (err: any) { + lastError = err; + console.log(`[Botanical Analysis] Model ${model} returned status: API_BUSY_OR_UNAVAILABLE`); + // If we still have attempts, sleep with exponential backoff + if (attempt < maxAttempts) { + const sleepMs = attempt * 1500; + console.log(`[Botanical Analysis] Sleeping ${sleepMs}ms before next model attempt...`); + await new Promise((resolve) => setTimeout(resolve, sleepMs)); + } + } + } + throw lastError || new Error("Se rebasaron todos los reintentos"); + } + + // Scan Plant Endpoint + app.post("/api/scan-plant", async (req, res) => { + const { base64Image, mimeType, isPresetSeed, presetIndex, targetElement } = req.body || {}; + + // Guardar imagen en el bucket publico de Supabase Storage si se provee. + let savedImagePath = ""; + if (base64Image && /^data:image\/[a-zA-Z0-9+.-]+;base64,/.test(base64Image)) { + try { + savedImagePath = await withTimeout( + uploadScanImageToSupabase(base64Image, mimeType), + 3500, + "La subida a Supabase Storage tardó demasiado." + ); + console.log(`📸 Imagen de escaneo guardada exitosamente en Supabase Storage (${PLANT_IMAGES_BUCKET}): ${savedImagePath}`); + } catch (err) { + console.error(`Error al guardar la imagen en Supabase Storage (${PLANT_IMAGES_BUCKET}):`, err); + } + } + + // Local quick sandbox preview presets + if (isPresetSeed) { + const idx = Number(presetIndex) >= 0 && Number(presetIndex) < PRESETS_BOTANICAL.length ? Number(presetIndex) : 0; + const item = PRESETS_BOTANICAL[idx]; + return res.json({ + success: true, + data: item, + method: "Pregenerado", + }); + } + + // Direct element override or selective mock pathway + if (targetElement && targetElement !== "Auto-detectar") { + const normalizedTarget = targetElement.toLowerCase() + .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // remove accents (e.g. raíz -> raiz) + .replace("clorofilia", "clorofila"); + + const matchedPreset = PRESETS_BOTANICAL.find(p => { + const pElement = (p.detectedElement || "").toLowerCase() + .normalize("NFD").replace(/[\u0300-\u036f]/g, "") + .replace("clorofilia", "clorofila"); + return pElement === normalizedTarget; + }); + + if (matchedPreset) { + console.log(`🎯 [Filtro Óptico] Retornando preset pre-integrado exacto para la estructura: ${targetElement}`); + return res.json({ + success: true, + data: { + ...matchedPreset, + image: savedImagePath || base64Image || matchedPreset.image, + detectedElement: targetElement // Preserve the exact title requested by the user + }, + method: "Análisis Óptico Directo", + }); + } + } + + const client = getGeminiClient(); + if (!client) { + console.log("Fallback modo simulación por falta de API Key."); + let selectedPreset = PRESETS_BOTANICAL[Math.floor(Math.random() * PRESETS_BOTANICAL.length)]; + + // If a targetElement was requested, try to find a match + if (targetElement && targetElement !== "Auto-detectar") { + const normalizedTarget = targetElement.toLowerCase() + .normalize("NFD").replace(/[\u0300-\u036f]/g, "") + .replace("clorofilia", "clorofila"); + + const found = PRESETS_BOTANICAL.find(p => { + const pElement = (p.detectedElement || "").toLowerCase() + .normalize("NFD").replace(/[\u0300-\u036f]/g, "") + .replace("clorofilia", "clorofila"); + return pElement === normalizedTarget; + }); + if (found) { + selectedPreset = found; + } + } + + const item = { ...selectedPreset }; + item.image = savedImagePath || getFallbackImageByPlantName(item.name); + return res.json({ + success: true, + data: item, + method: "Respaldo Local", + warning: "GEMINI_API_KEY no configurada o no válida. Cargada ficha botánica clasificada para " + (item.detectedElement || "Flora") + }); + } + + if (!base64Image) { + return res.status(400).json({ error: "Falta el archivo de imagen base64." }); + } + + try { + const cleanBase64 = base64Image.replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, ""); + const imgPart = { + inlineData: { + mimeType: mimeType || "image/jpeg", + data: cleanBase64, + }, + }; + + const textPart = { + text: "Analiza y examina detalladamente esta imagen vegetal. Identifica rigurosamente tanto la planta como su estructura o elemento visible principal. Devuelve estrictamente un objeto JSON en español que cumpla con el esquema requerido, asegurando que 'detectedElement' corresponda exactamente a uno de estos once términos según corresponda al aspecto visible en la imagen." + + (targetElement && targetElement !== "Auto-detectar" ? ` Nota especial: Enfoca prioritariamente la identificación en la estructura vegetal clasificada como: "${targetElement}".` : ""), + }; + + const responseSchema = { + type: Type.OBJECT, + properties: { + name: { type: Type.STRING, description: "Nombre común de la planta en español" }, + scientificName: { type: Type.STRING, description: "Nombre científico en latín" }, + origin: { type: Type.STRING, description: "Origen geográfico de la planta" }, + uses: { type: Type.STRING, description: "Para qué sirve y sus principales utilidades (comestible, medicinal, etc.) en español" }, + description: { type: Type.STRING, description: "Breve descripción botánica clara y cautivadora en español" }, + difficulty: { type: Type.STRING, description: "Dificultad de cultivo recomendado: 'Fácil', 'Moderado' o 'Difícil'" }, + suggestedPriceSol: { type: Type.NUMBER, description: "Precio sugerido de venta en SOL por ración (entre 0.01 y 0.1)" }, + suggestedPriceUsdc: { type: Type.NUMBER, description: "Precio sugerido en USDC (entre 0.5 y 3.0)" }, + suggestedPriceUsdt: { type: Type.NUMBER, description: "Precio sugerido en USDT (entre 0.5 y 3.0)" }, + category: { type: Type.STRING, description: "Categoría de cultivo: 'Hortalizas', 'Medicinales', 'Frutas', 'Hierbas' o 'Otro'" }, + watering: { type: Type.STRING, description: "Consejos de riego específicos para esta planta en español" }, + sunlight: { type: Type.STRING, description: "Requerimientos de luz solar y clima y exposición ideales en español" }, + idealSowingSeason: { type: Type.STRING, description: "Temporada o época ideal del año recomendada para sembrar en español" }, + harvestTimeDays: { type: Type.STRING, description: "Tiempo estimado (días, semanas, o meses) hasta ver la primera cosecha útil en español" }, + soilType: { type: Type.STRING, description: "Tipo de suelo, sustrato o tierra ideales para el crecimiento en español" }, + phRecommended: { type: Type.STRING, description: "Nivel de pH del suelo recomendado u óptimo" }, + companionPlants: { type: Type.STRING, description: "Plantas compañeras ideales que benefician su crecimiento en español" }, + pestPrevention: { type: Type.STRING, description: "Métodos ecológicos o remedios caseros para prevenir sus plagas habituales en español" }, + detectedElement: { type: Type.STRING, description: "El elemento o estructura vegetal detectado principalmente en la foto. Debe ser estrictamente uno de los siguientes: 'Plantas', 'Frutas', 'Frutos', 'Hojas', 'Clorofila', 'Raíz', 'Tallo', 'Flor', 'Semilla', 'Savia', 'Estomas'." }, + }, + required: [ + "name", + "scientificName", + "origin", + "uses", + "description", + "difficulty", + "suggestedPriceSol", + "suggestedPriceUsdc", + "suggestedPriceUsdt", + "category", + "watering", + "sunlight", + "idealSowingSeason", + "harvestTimeDays", + "soilType", + "phRecommended", + "companionPlants", + "pestPrevention", + "detectedElement", + ], + }; + + const response = await withTimeout( + generateBotanicalContentWithRetry(client, imgPart, textPart, responseSchema), + 8000, + "El análisis de Gemini tardó demasiado." + ); + + if (response && response.text) { + const parsedData = JSON.parse(response.text.trim()); + // Preserve user scanned local image path when available, otherwise fall back to Unsplash + parsedData.image = savedImagePath || getFallbackImageByPlantName(parsedData.name); + res.json({ + success: true, + data: parsedData, + method: "Gemini AI", + }); + } else { + throw new Error("No text response from Gemini"); + } + } catch (error: any) { + console.log("ℹ️ [Gemini Status] Nota: Las peticiones a la API de Gemini están muy congestionadas en este momento. Se ha activado la ficha botánica pre-integrada del invernadero local de forma automática."); + const randomIndex = Math.floor(Math.random() * PRESETS_BOTANICAL.length); + const item = { ...PRESETS_BOTANICAL[randomIndex] }; + // Always assign savedImagePath if available, then Unsplash fallback, never the huge base64 + item.image = savedImagePath || getFallbackImageByPlantName(item.name); + res.json({ + success: true, + data: item, + method: "Simulado", + error: error.message || "Servicio temporalmente congestionado", + }); + } + }); + + // Vite middleware setup + if (process.env.NODE_ENV !== "production") { + const vite = await createViteServer({ + server: { middlewareMode: true }, + appType: "spa", + }); + app.use(vite.middlewares); + } else { + const distPath = path.join(process.cwd(), "dist"); + app.use(express.static(distPath)); + app.get("*", (req, res) => { + res.sendFile(path.join(distPath, "index.html")); + }); + } + + if (!process.env.VERCEL) { + app.listen(REAL_PORT, "0.0.0.0", () => { + console.log(`🚀 Servidor full-stack corriendo en http://localhost:${REAL_PORT}`); + }); + } +} + +startServer(); diff --git a/api/index.ts b/api/index.ts index 0c14791..09c00c9 100644 --- a/api/index.ts +++ b/api/index.ts @@ -1,3 +1,3 @@ -import { app } from "../server"; +import { app } from "./app.js"; export default app; diff --git a/server.ts b/server.ts index 02ad040..1e5b81e 100644 --- a/server.ts +++ b/server.ts @@ -1,975 +1 @@ -import express from "express"; -import path from "path"; -import { fileURLToPath } from "url"; -import { GoogleGenAI, Type } from "@google/genai"; -import dotenv from "dotenv"; -import { createServer as createViteServer } from "vite"; -import fs from "fs"; -import { createClient } from "@supabase/supabase-js"; - -dotenv.config(); - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// Supabase configuration -const rawSupabaseUrl = "https://klaompnbmjufvhjkeeno.supabase.co"; -const supabaseUrl = rawSupabaseUrl.replace(/\/rest\/v1\/?$/, ''); -const supabaseAnonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtsYW9tcG5ibWp1ZnZoamtlZW5vIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODE1NjY5ODEsImV4cCI6MjA5NzE0Mjk4MX0.udKgeFZLsVzXvSU0oqR0F3_J7EDCA1g7MxF00l8LEEc"; -const supabase = createClient(supabaseUrl, supabaseAnonKey); - -// Initialize Gemini Client Lazily/Safely -let aiClient: GoogleGenAI | null = null; -const getGeminiClient = (): GoogleGenAI | null => { - const apiKey = process.env.GEMINI_API_KEY; - if (!apiKey || apiKey === "MY_GEMINI_API_KEY") { - console.warn("⚠️ Advertencia: GEMINI_API_KEY no está configurada o usa el marcador por defecto."); - return null; - } - if (!aiClient) { - try { - aiClient = new GoogleGenAI({ - apiKey: apiKey, - httpOptions: { - headers: { - "User-Agent": "aistudio-build", - }, - }, - }); - console.log("✅ Cliente Gemini configurado exitosamente."); - } catch (e) { - console.error("❌ Error al instanciar el cliente Gemini:", e); - } - } - return aiClient; -}; - -// Mock standard database of crop results to fallback in case of errors / no API key -const PRESETS_BOTANICAL: Array<{ - name: string; - scientificName: string; - origin: string; - uses: string; - description: string; - difficulty: "Fácil" | "Moderado" | "Difícil"; - suggestedPriceSol: number; - suggestedPriceUsdc: number; - suggestedPriceUsdt: number; - category: "Hortalizas" | "Medicinales" | "Frutas" | "Hierbas" | "Otro"; - watering: string; - sunlight: string; - idealSowingSeason: string; - harvestTimeDays: string; - soilType: string; - phRecommended: string; - companionPlants: string; - pestPrevention: string; - detectedElement?: string; - image: string; -}> = [ - { - name: "Rábano Fast-Grow", - scientificName: "Raphanus sativus", - origin: "Eurasia", - detectedElement: "Raíz", - uses: "Alimenticio: Consumo en ensaladas, aporta textura crujiente y toque picante. Alto en vitamina C y fibra.", - description: "Pequeña raíz globosa de color rojo intenso con pulpa blanca, crujiente y refrescante.", - difficulty: "Fácil", - suggestedPriceSol: 0.015, - suggestedPriceUsdc: 0.75, - suggestedPriceUsdt: 0.75, - category: "Hortalizas", - watering: "Riego regular y uniforme para evitar que la raíz se agriete o se ponga demasiado picante.", - sunlight: "Sol pleno o semisombra.", - idealSowingSeason: "Primavera, Otoño y finales de Verano.", - harvestTimeDays: "21 a 30 días", - soilType: "Suelo suelto, ligero, bien drenado y rico en materia orgánica.", - phRecommended: "6.0 a 7.0.", - companionPlants: "Espinaca, lechuga y guisantes. Ayuda a dispersar plagas comunes.", - pestPrevention: "Proteger con malla anti-insectos en las primeras etapas y vigilar el escarabajo pulga.", - image: "https://images.unsplash.com/photo-1590005354167-6da97870c913?auto=format&fit=crop&q=80&w=300" - }, - { - name: "Menta Piperita", - scientificName: "Mentha x piperita", - origin: "Europa", - uses: "Medicinal: Alivia espasmos, digestiones difíciles e infusión relajante. Culinario: Aromatizante de licores y postres.", - description: "Planta herbácea perenne muy aromática, de tallos cuadrangulares rojizos y hojas dentadas con intenso olor a mentol.", - difficulty: "Fácil", - suggestedPriceSol: 0.015, - suggestedPriceUsdc: 0.75, - suggestedPriceUsdt: 0.75, - category: "Medicinales", - watering: "Riego abundante y regular. Prefiere suelos húmedos de forma continua pero con desague óptimo.", - sunlight: "Sombra parcial o semisombra. Prefiere luz tamizada indirecta.", - idealSowingSeason: "Principios de Primavera u Otoño (es altamente invasiva, preferir cultivo en macetas separadas).", - harvestTimeDays: "60 a 70 días tras la siembra.", - soilType: "Suelo arcilloso o suelto pero muy rico en materia orgánica con alta capacidad de retención de humedad.", - phRecommended: "6.5 a 7.0.", - companionPlants: "Repollo, coliflor y lechuga. Repele plagas de orugas. ¡No plantar cerca de manzanilla o perejil!", - pestPrevention: "Podar a ras de suelo en Otoño para un resurgir vigoroso en Primavera. Controlar caracoles manualmente.", - detectedElement: "Hojas", - image: "https://images.unsplash.com/photo-1608686207856-001b95cf60ca?auto=format&fit=crop&q=80&w=300" - }, - { - name: "Frutilla Silvestre", - scientificName: "Fragaria vesca", - origin: "Eurasia", - uses: "Alimenticio: Consumo fresco, mermeladas y helados. Rica en antioxidantes, ácido fólico y vitamina C.", - description: "Planta rastrera perenne que produce estolones, hojas trifoliadas dentadas y pequeños frutos rojos muy fragantes y dulces.", - difficulty: "Moderado", - suggestedPriceSol: 0.06, - suggestedPriceUsdc: 2.50, - suggestedPriceUsdt: 2.50, - category: "Frutas", - watering: "Moderado y constante. El método de goteo es excelente para proteger la corona de la planta de pudriciones.", - sunlight: "Pleno sol para máxima fructificación y sabor dulce, pero tolera algo de semisombra.", - idealSowingSeason: "A finales de Otoño o principios de Primavera.", - harvestTimeDays: "90 a 120 días tras la plantación.", - soilType: "Suelo arenoso rico en humus, bien acolchado con paja seca para evitar que las frutillas toquen la tierra.", - phRecommended: "5.8 a 6.2.", - companionPlants: "Espinacas, cebollas y borraja. Evitar plantar cerca de otras solanáceas o brassicas.", - pestPrevention: "Abonar con compost enriquecido en potasio. Vigilar la aparición de pulgones rociando jabón potásico.", - detectedElement: "Frutas", - image: "https://images.unsplash.com/photo-1464965911861-746a04b4bca6?auto=format&fit=crop&q=80&w=300" - }, - { - name: "Helecho Espada Sol", - scientificName: "Nephrolepis exaltata", - origin: "Zonas tropicales de América y Asia", - uses: "Ornamental: Purifica el aire interior capturando formaldehído. Excelente para colgar en canastos.", - description: "Espectacular planta perenne de frondas arqueadas y plumosas, que aporta un follaje verde y tupido sumamente decorativo.", - difficulty: "Fácil", - suggestedPriceSol: 0.03, - suggestedPriceUsdc: 1.20, - suggestedPriceUsdt: 1.20, - category: "Otro", - watering: "Frecuente para mantener el sustrato constantemente húmedo pero sin encharcar la maceta.", - sunlight: "Luz indirecta brillante o semisombra. Evitar el sol directo que quema sus delicadas frondas.", - idealSowingSeason: "Primavera u Otoño húmedo.", - harvestTimeDays: "Crecimiento constante todo el año", - soilType: "Sustrato a base de turba, poroso, rico en nutrientes y con excelente drenaje de agua.", - phRecommended: "5.5 a 6.0.", - companionPlants: "Orquídeas, potos y otras plantas que disfrutan de alta humedad ambiental.", - pestPrevention: "Mantener alta humedad pulverizando agua regularmente para ahuyentar a la araña roja.", - detectedElement: "Plantas", - image: "https://images.unsplash.com/photo-1545241047-6083a3684587?auto=format&fit=crop&q=80&w=300" - }, - { - name: "Pimiento Dulce", - scientificName: "Capsicum annuum", - origin: "Mesoamérica", - uses: "Culinario: Consumo fresco, asado o frito. Fuente excelente de vitaminas A y C esenciales.", - description: "Fruto hueco de paredes carnosas y jugosas que cambia de verde a colores brillantes rojos, amarillos o naranjas al madurar.", - difficulty: "Moderado", - suggestedPriceSol: 0.04, - suggestedPriceUsdc: 1.50, - suggestedPriceUsdt: 1.50, - category: "Hortalizas", - watering: "Humedad regular. Evitar estrés hídrico durante la floración y el desarrollo del fruto.", - sunlight: "Pleno sol constante y temperaturas cálidas para un óptimo cuajado de frutos.", - idealSowingSeason: "Finales de Invierno o principios de Primavera.", - harvestTimeDays: "70 a 90 días", - soilType: "Suelo fértil, profundo, rico en materia orgánica y con excelente desague.", - phRecommended: "6.0 a 6.8.", - companionPlants: "Albahaca, tomate, cebollas y cilantro de huerto.", - pestPrevention: "Uso de trampas cromáticas amarillas y pulverización preventiva con jabón potásico para pulgones.", - detectedElement: "Frutos", - image: "https://images.unsplash.com/photo-1563861826100-9cb868fdcd1e?auto=format&fit=crop&q=80&w=300" - }, - { - name: "Espinaca Clorofílica", - scientificName: "Spinacia oleracea", - origin: "Persia antigua", - uses: "Alimenticio: Gran aporte de hierro, ácido fólico y clorofila depurativa. Se consume fresca o cocida.", - description: "Hojas carnosas de color verde oscuro brillante dispuestas en roseta, ricas en clorofila y fitonutrientes saludables.", - difficulty: "Fácil", - suggestedPriceSol: 0.02, - suggestedPriceUsdc: 1.00, - suggestedPriceUsdt: 1.00, - category: "Hortalizas", - watering: "Riego regular y moderado, manteniendo la tierra uniformemente húmeda pero nunca pesada.", - sunlight: "Semisombra o sol parcial. La luz solar excesiva puede acelerar la producción prematura de semillas.", - idealSowingSeason: "Otoño y Primavera temprana para disfrutar del clima fresco.", - harvestTimeDays: "40 a 50 días", - soilType: "Suelos pesados o francos, muy ricos en nitrógeno orgánico.", - phRecommended: "6.5 a 7.5.", - companionPlants: "Frutillas, habas, guisantes y repollo.", - pestPrevention: "Remover malezas manualmente y controlar orugas con tratamientos con Bacillus thuringiensis de ser necesario.", - detectedElement: "Clorofila", - image: "https://images.unsplash.com/photo-1576045057995-568f588f82fb?auto=format&fit=crop&q=80&w=300" - }, - { - name: "Apio de Sacramento", - scientificName: "Apium graveolens", - origin: "Zonas mediterráneas", - uses: "Culinario: Cocción en caldos, sopas o consumo de tallos crujientes en ensaladas y jugos desintoxicantes.", - description: "Planta con gruesos tallos acanalados y fibrosos que forman una corona compacta de color verde pálido.", - difficulty: "Difícil", - suggestedPriceSol: 0.035, - suggestedPriceUsdc: 1.40, - suggestedPriceUsdt: 1.40, - category: "Hortalizas", - watering: "Exigente en riego continuo. Necesita humedad constante y un sustrato rico en nutrientes.", - sunlight: "Semisombra o pleno sol con protección frente a vientos secos.", - idealSowingSeason: "Primavera para cosecha en Otoño.", - harvestTimeDays: "120 a 150 días", - soilType: "Suelo de huerta pesado o arcilloso, muy fértil y retenedor de agua.", - phRecommended: "6.0 a 7.0.", - companionPlants: "Cebolla, ajo, coliflor y tomates.", - pestPrevention: "Abonar intensivamente y pulverizar decocciones de cola de caballo para prevenir hongos.", - detectedElement: "Tallo", - image: "https://images.unsplash.com/photo-1610970881699-44a5587cabec?auto=format&fit=crop&q=80&w=300" - }, - { - name: "Manzanilla de la Reina", - scientificName: "Matricaria chamomilla", - origin: "Europa y Asia templada", - uses: "Infusión medicinal: Aliviador gástrico y sedante natural. Antiinflamatorio ocular.", - description: "Planta herbácea con pequeñas flores similares a margaritas, que emiten una fragancia dulce y relajante.", - difficulty: "Fácil", - suggestedPriceSol: 0.02, - suggestedPriceUsdc: 0.90, - suggestedPriceUsdt: 0.90, - category: "Medicinales", - watering: "Moderado. Soporta muy bien periodos cortos de sequía una vez establecida.", - sunlight: "Pleno sol para maximizar la concentración de aceites esenciales curativos.", - idealSowingSeason: "Otoño o Primavera directa a suelo.", - harvestTimeDays: "60 a 80 días", - soilType: "Suelo liviano, arenoso y no demasiado fértil.", - phRecommended: "6.0 a 7.2.", - companionPlants: "Cebollas, coles y trigo. Mejora el sabor de vecinas aromáticas.", - pestPrevention: "Evitar suelos pesados que propicien pudrición de raíz. Pulverizar agua con ajo si surge pulgón.", - detectedElement: "Flor", - image: "https://images.unsplash.com/photo-1588145293284-cd9d282e4e13?auto=format&fit=crop&q=80&w=300" - }, - { - name: "Girasol de Oro", - scientificName: "Helianthus annuus", - origin: "Norteamérica", - uses: "Culinario: Extracción de aceite de alta cocina y snack saludable de semillas crujientes de girasol.", - description: "Hermosa e imponente inflorescencia amarilla gigante que sigue la trayectoria solar diaria, albergando cientos de ricas semillas.", - difficulty: "Fácil", - suggestedPriceSol: 0.025, - suggestedPriceUsdc: 1.10, - suggestedPriceUsdt: 1.10, - category: "Hierbas", - watering: "Moderado pero profundo. Tolera sequía moderada gracias a su raíz pivotante larga.", - sunlight: "Sol directo absoluto (mínimo de 6 a 8 horas diarias prescritas).", - idealSowingSeason: "Mediados de Primavera, tras desaparecer las heladas.", - harvestTimeDays: "80 a 110 días", - soilType: "Suelo suelto y profundo que permita la libre extensión de su raíz.", - phRecommended: "6.0 a 7.5.", - companionPlants: "Maíz, pepinos y calabazas montantes.", - pestPrevention: "Proteger las flores maduras de las aves con mallas finas si se desea conservar las semillas intactas.", - detectedElement: "Semilla", - image: "https://images.unsplash.com/photo-1595855759920-86582396756a?auto=format&fit=crop&q=80&w=300" - }, - { - name: "Sangre de Dragón", - scientificName: "Croton lechleri", - origin: "Amazonía americana", - uses: "Medicinal: Cicatrizante ultrapotente de heridas y protector celular cutáneo ante patógenos.", - description: "Árbol tropical cuya corteza, al ser raspada o cortada, secreta una savia rojiza espesa rica en taspina.", - difficulty: "Difícil", - suggestedPriceSol: 0.09, - suggestedPriceUsdc: 3.50, - suggestedPriceUsdt: 3.50, - category: "Medicinales", - watering: "Riego espaciado simulando las abundantes lluvias de selva.", - sunlight: "Cálido y húmedo con sol directo filtrado o tamizado.", - idealSowingSeason: "Estación lluviosa tropical.", - harvestTimeDays: "Varios años para el desarrollo óptimo de su tronco.", - soilType: "Suelo de bosque húmedo con abundante humus ácido.", - phRecommended: "5.0 a 6.0.", - companionPlants: "Helechos tropicales, bromelias y cacao de monte.", - pestPrevention: "Tratar plagas fúngicas de hojas tiernas con macerado purificante de ajo y jengibre.", - detectedElement: "Savia", - image: "https://images.unsplash.com/photo-1502082553048-f009c37129b9?auto=format&fit=crop&q=80&w=300" - }, - { - name: "Hiedra de Pared", - scientificName: "Hedera helix", - origin: "Europa, Asia y África", - uses: "Funcional: Cubrimiento térmico estético de muros e investigación botánica de estomas activos en hojas perennes.", - description: "Planta trepadora perenne con hojas verdes brillantes coriáceas muy estudiada para revelar estomas microscópicos.", - difficulty: "Fácil", - suggestedPriceSol: 0.02, - suggestedPriceUsdc: 0.85, - suggestedPriceUsdt: 0.85, - category: "Otro", - watering: "Moderado. Dejar secar ligeramente la capa superior de tierra antes del siguiente riego.", - sunlight: "Prefiere sombra o luz tamizada.", - idealSowingSeason: "Primavera u Otoño en climas templados.", - harvestTimeDays: "Crecimiento constante rápido.", - soilType: "Cualquier suelo bien drenado, tolera suelos pobres en nutrientes.", - phRecommended: "6.0 a 7.5.", - companionPlants: "Cualquier planta de sotobosque y helechos.", - pestPrevention: "Humedecer el follaje para prevenir ácaros y cochinillas en épocas de calor extremo.", - detectedElement: "Estomas", - image: "https://images.unsplash.com/photo-1508739773434-c26b3d09e071?auto=format&fit=crop&q=80&w=300" - } -]; - -// Memory state to support persistence over developer server session -interface ServerCrop { - id: string; - name: string; - scientificName: string; - origin: string; - uses: string; - description: string; - difficulty: "Fácil" | "Moderado" | "Difícil"; - image: string; - priceSol: number; - priceUsdc: number; - priceUsdt: number; - stock: number; - isForSale: boolean; - category: "Hortalizas" | "Medicinales" | "Frutas" | "Hierbas" | "Otro"; - watering?: string; - sunlight?: string; - idealSowingSeason?: string; - harvestTimeDays?: string; - soilType?: string; - phRecommended?: string; - companionPlants?: string; - pestPrevention?: string; - detectedElement?: string; -} - -interface ServerLedgerLog { - id: string; - timestamp: string; - cropName: string; - quantity: number; - amount: number; - currency: "SOL" | "USDC" | "USDT"; - signature: string; - status: "EXITOSO" | "PENDIENTE" | "FALLIDO"; -} - -const CROPS_FILE = path.join(process.cwd(), "crops_data.json"); -const LEDGER_FILE = path.join(process.cwd(), "ledger_data.json"); -const VOLUME_FILE = path.join(process.cwd(), "volume_data.json"); - -// Helper function to resolve high-quality Unsplash image by plant/crop name -function getFallbackImageByPlantName(name: string): string { - const lowercase = (name || "").toLowerCase(); - - if (lowercase.includes("cafeto") || lowercase.includes("café") || lowercase.includes("coffee")) { - return "https://images.unsplash.com/photo-1514432324607-a09d9b4aefdd?auto=format&fit=crop&q=80&w=300"; - } - if (lowercase.includes("caña") || lowercase.includes("sugarcane")) { - return "https://images.unsplash.com/photo-1543257580-7269da773bf5?auto=format&fit=crop&q=80&w=300"; - } - if (lowercase.includes("plátano") || lowercase.includes("platano") || lowercase.includes("banano") || lowercase.includes("banana") || lowercase.includes("guineo")) { - return "https://images.unsplash.com/photo-1571771894821-ce9b6c11b08e?auto=format&fit=crop&q=80&w=300"; - } - if (lowercase.includes("aguacate") || lowercase.includes("avocado") || lowercase.includes("palta")) { - return "https://images.unsplash.com/photo-1523049673857-eb18f1d7b578?auto=format&fit=crop&q=80&w=300"; - } - if (lowercase.includes("naranja") || lowercase.includes("orange") || lowercase.includes("cítrico") || lowercase.includes("citrico")) { - return "https://images.unsplash.com/photo-1547514701-42782101795e?auto=format&fit=crop&q=80&w=300"; - } - if (lowercase.includes("rabano") || lowercase.includes("radish")) { - return "https://images.unsplash.com/photo-1590005354167-6da97870c913?auto=format&fit=crop&q=80&w=300"; - } - if (lowercase.includes("menta") || lowercase.includes("mint") || lowercase.includes("piperita")) { - return "https://images.unsplash.com/photo-1608686207856-001b95cf60ca?auto=format&fit=crop&q=80&w=300"; - } - if (lowercase.includes("frutilla") || lowercase.includes("fresa") || lowercase.includes("strawberry")) { - return "https://images.unsplash.com/photo-1464965911861-746a04b4bca6?auto=format&fit=crop&q=80&w=300"; - } - - return `https://images.unsplash.com/photo-1466692476868-aef1dfb1e735?auto=format&fit=crop&q=80&w=300`; -} - -function loadCrops(): ServerCrop[] { - try { - if (fs.existsSync(CROPS_FILE)) { - const data = fs.readFileSync(CROPS_FILE, "utf-8"); - const list: ServerCrop[] = JSON.parse(data); - let updated = false; - const sanitized = list.map(c => { - // Purge base64 / excessively large image strings that cause QuotaExceededError - if (c.image && (c.image.startsWith("data:") || c.image.length > 1000)) { - c.image = getFallbackImageByPlantName(c.name); - updated = true; - } - return c; - }); - if (updated) { - saveCrops(sanitized); - } - return sanitized; - } - } catch (e) { - console.error("Error al cargar crops_data.json:", e); - } - return [ - { - id: "crop-2", - name: "Rábano Fast-Grow", - detectedElement: "Raíz", - scientificName: "Raphanus sativus", - origin: "Eurasia", - uses: "Alimenticio: Consumo en ensaladas, aporta textura crujiente y toque picante. Alto en vitamina C.", - description: "Pequeña raíz globosa de color rojo intenso con pulpa blanca, crujiente y refrescante.", - difficulty: "Fácil", - image: "https://images.unsplash.com/photo-1590005354167-6da97870c913?auto=format&fit=crop&q=80&w=300", - priceSol: 0.015, - priceUsdc: 0.75, - priceUsdt: 0.75, - stock: 5, - isForSale: true, - category: "Hortalizas", - watering: "Riego regular y uniforme para evitar que la raíz se agriete o se ponga demasiado picante.", - sunlight: "Sol pleno o semisombra.", - idealSowingSeason: "Primavera, Otoño.", - harvestTimeDays: "21 a 30 días.", - soilType: "Suelo suelto, ligero, bien drenado.", - phRecommended: "6.0 a 7.0.", - companionPlants: "Espinaca, lechuga y guisantes.", - pestPrevention: "Proteger con malla anti-insectos." - } - ]; -} - -function saveCrops(crops: ServerCrop[]) { - try { - fs.writeFileSync(CROPS_FILE, JSON.stringify(crops, null, 2), "utf-8"); - } catch (e) { - console.error("Error al guardar crops_data.json:", e); - } -} - -function loadLedger(): ServerLedgerLog[] { - try { - if (fs.existsSync(LEDGER_FILE)) { - const data = fs.readFileSync(LEDGER_FILE, "utf-8"); - return JSON.parse(data); - } - } catch (e) { - console.error("Error al cargar ledger_data.json:", e); - } - return [ - { - id: "tx-1", - timestamp: new Date(Date.now() - 30 * 60000).toISOString(), - cropName: "Rábano Fast-Grow", - quantity: 1, - amount: 0.75, - currency: "USDC", - signature: "5R7P37v6y8X9qZd2B1cK3eHgFdSjKa8s9dF2gH1jK3l7s9z2x3c4v5b6n7m8", - status: "EXITOSO", - }, - { - id: "tx-2", - timestamp: new Date(Date.now() - 15 * 60000).toISOString(), - cropName: "Rábano Fast-Grow", - quantity: 1, - amount: 0.75, - currency: "USDT", - signature: "5R2W9qZd2B1cK3eHgFdSjKa8s9dF2gH1jK3l7s9z2x3c4v5b6n7m8tx3k2l19", - status: "EXITOSO", - } - ]; -} - -function saveLedger(ledger: ServerLedgerLog[]) { - try { - fs.writeFileSync(LEDGER_FILE, JSON.stringify(ledger, null, 2), "utf-8"); - } catch (e) { - console.error("Error al guardar ledger_data.json:", e); - } -} - -function loadVolume(): number { - try { - if (fs.existsSync(VOLUME_FILE)) { - const data = fs.readFileSync(VOLUME_FILE, "utf-8"); - return Number(data) || 28.62; - } - } catch (e) { - console.error("Error al cargar volume_data.json:", e); - } - return 28.62; -} - -function saveVolume(volume: number) { - try { - fs.writeFileSync(VOLUME_FILE, String(volume), "utf-8"); - } catch (e) { - console.error("Error al guardar volume_data.json:", e); - } -} - -let activeCrops: ServerCrop[] = loadCrops(); -let paymentLedger: ServerLedgerLog[] = loadLedger(); -let mockVolumenSalesUsd = loadVolume(); - -export const app = express(); - -async function startServer() { - const REAL_PORT = 3000; - - app.use(express.json({ limit: "15mb" })); - app.use(express.urlencoded({ extended: true, limit: "15mb" })); - app.use("/src/Imagenes", express.static(path.join(process.cwd(), "src", "Imagenes"))); - app.use("/src/imagenes", express.static(path.join(process.cwd(), "src", "imagenes"))); - - // API Endpoints - app.get("/api/crops", async (req, res) => { - try { - const { data, error } = await supabase.from('crops').select('*'); - if (error) throw error; - res.json(data || []); - } catch (error: any) { - console.error("Supabase /api/crops GET error:", error.message || error); - res.json(activeCrops); - } - }); - - app.post("/api/crops", async (req, res) => { - const { - id, name, scientificName, origin, uses, description, difficulty, - priceSol, priceUsdc, priceUsdt, stock, isForSale, category, image, - watering, sunlight, idealSowingSeason, harvestTimeDays, soilType, - phRecommended, companionPlants, pestPrevention, detectedElement - } = req.body; - - const newCrop = { - id: id || `crop-${Date.now()}`, - name: name || "Cultivo Desconocido", - scientificName: scientificName || "Incognita", - origin: origin || "Desconocido", - uses: uses || "No especificado", - description: description || "No disponible", - difficulty: (difficulty || "Fácil"), - image: image || "", - priceSol: Number(priceSol) || 0.01, - priceUsdc: Number(priceUsdc) || 0.5, - priceUsdt: Number(priceUsdt) || 0.5, - stock: Number(stock) || 5, - isForSale: isForSale !== undefined ? isForSale : false, - category: category || "Otro", - watering: watering || "", - sunlight: sunlight || "", - idealSowingSeason: idealSowingSeason || "", - harvestTimeDays: harvestTimeDays || "", - soilType: soilType || "", - phRecommended: phRecommended || "", - companionPlants: companionPlants || "", - pestPrevention: pestPrevention || "", - detectedElement: detectedElement || "Plantas" - }; - - try { - // Check if exists - const { data: existing } = await supabase.from('crops').select('id').eq('id', newCrop.id).single(); - - if (existing) { - return res.json(newCrop); - } - - const { data, error } = await supabase.from('crops').insert([newCrop]).select(); - if (error) throw error; - - res.status(201).json(data?.[0] || newCrop); - } catch (error: any) { - console.error("Supabase /api/crops POST error:", JSON.stringify(error, null, 2), error.message); - if (!activeCrops.find(c => c.id === newCrop.id)) { - activeCrops.push(newCrop); - saveCrops(activeCrops); - } - res.status(201).json(newCrop); - } - }); - - app.put("/api/crops/:id", async (req, res) => { - const { id } = req.params; - try { - const { data, error } = await supabase.from('crops').update(req.body).eq('id', id).select(); - - if (error) throw error; - - if (data && data.length > 0) { - res.json(data[0]); - } else { - res.status(404).json({ error: "Cultivo no encontrado" }); - } - } catch (error: any) { - console.error("Supabase /api/crops PUT error:", error); - const idx = activeCrops.findIndex(c => c.id === id); - if (idx !== -1) { - activeCrops[idx] = { ...activeCrops[idx], ...req.body }; - saveCrops(activeCrops); - res.json(activeCrops[idx]); - } else { - res.status(404).json({ error: "Cultivo no encontrado localmente" }); - } - } - }); - - app.delete("/api/crops/:id", async (req, res) => { - const { id } = req.params; - try { - const { error } = await supabase.from('crops').delete().eq('id', id); - if (error) throw error; - res.json({ success: true }); - } catch (error: any) { - console.error("Supabase /api/crops DELETE error:", error); - activeCrops = activeCrops.filter(c => c.id !== id); - saveCrops(activeCrops); - res.json({ success: true }); - } - }); - - // Payment Ledger Endpoints - app.get("/api/ledger", async (req, res) => { - try { - const { data: ledgerData, error: ledgerError } = await supabase.from('ledger').select('*').order('timestamp', { ascending: false }); - if (ledgerError) throw ledgerError; - - const { data: volumeData } = await supabase.from('store_metrics').select('totalSalesUsd').eq('id', 'main').single(); - - res.json({ - ledger: ledgerData || [], - totalSalesUsd: volumeData?.totalSalesUsd || mockVolumenSalesUsd || 0, - }); - } catch (error: any) { - console.error("Supabase /api/ledger GET error:", error); - res.json({ - ledger: paymentLedger, - totalSalesUsd: mockVolumenSalesUsd - }); - } - }); - - app.post("/api/ledger", async (req, res) => { - const { cropName, quantity, amount, currency, signature, timestamp, id } = req.body; - - const newLog = { - id: id || `tx-${Date.now()}`, - timestamp: timestamp || new Date().toISOString(), - cropName: cropName || "Compra de Cultivo", - quantity: Number(quantity) || 1, - amount: Number(amount) || 0, - currency: currency || "SOL", - signature: signature || Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15), - status: "EXITOSO" as const - }; - - let usdValue = newLog.amount; - if (newLog.currency === "SOL") { - usdValue = newLog.amount * 150.0; - } - - try { - const { error } = await supabase.from('ledger').insert([newLog as any]); - if (error) throw error; - - const newVol = Number((mockVolumenSalesUsd + usdValue).toFixed(2)); - mockVolumenSalesUsd = newVol; - await supabase.from('store_metrics').upsert([{ id: 'main', totalSalesUsd: newVol }]); - - res.status(201).json({ log: newLog, totalSalesUsd: newVol }); - } catch (error: any) { - console.error("Supabase /api/ledger POST error:", error); - - if (!paymentLedger.find(l => l.id === newLog.id)) { - paymentLedger.unshift(newLog as ServerLedgerLog); - saveLedger(paymentLedger); - - const newVol = Number((mockVolumenSalesUsd + usdValue).toFixed(2)); - mockVolumenSalesUsd = newVol; - saveVolume(newVol); - } - - res.status(201).json({ log: newLog, totalSalesUsd: mockVolumenSalesUsd }); - } - }); - - app.delete("/api/ledger", async (req, res) => { - try { - await supabase.from('ledger').delete().neq('id', 'clear_all'); - await supabase.from('store_metrics').upsert([{ id: 'main', totalSalesUsd: 0 }]); - mockVolumenSalesUsd = 0; - - res.json({ success: true, totalSalesUsd: 0.00 }); - } catch (error: any) { - console.error("Supabase /api/ledger DELETE error:", error); - paymentLedger = []; - saveLedger(paymentLedger); - mockVolumenSalesUsd = 0; - saveVolume(0); - res.json({ success: true, totalSalesUsd: 0.00 }); - } - }); - - // Gemini Scan Helper with exponential retries and fallback model - async function generateBotanicalContentWithRetry(client: any, imgPart: any, textPart: any, schema: any) { - const maxAttempts = 3; - let lastError: any = null; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - // Alternate models: Attempt 1 uses gemini-3.5-flash. If busy, try gemini-3.1-flash-lite immediately - const model = attempt === 1 ? "gemini-3.5-flash" : "gemini-3.1-flash-lite"; - console.log(`🤖 [Botanical Analysis] Attempt ${attempt}/${maxAttempts} using model: ${model}`); - try { - const response = await client.models.generateContent({ - model: model, - contents: { parts: [imgPart, textPart] }, - config: { - systemInstruction: "Eres un asesor agrónomo especializado experto en botánica. Devuelve exclusivamente el esquema JSON solicitado sin textos aclaratorios, markdown fuera del JSON ni introducciones.", - responseMimeType: "application/json", - responseSchema: schema, - }, - }); - if (response && response.text) { - return response; - } - throw new Error("Respuesta vacia"); - } catch (err: any) { - lastError = err; - console.log(`[Botanical Analysis] Model ${model} returned status: API_BUSY_OR_UNAVAILABLE`); - // If we still have attempts, sleep with exponential backoff - if (attempt < maxAttempts) { - const sleepMs = attempt * 1500; - console.log(`[Botanical Analysis] Sleeping ${sleepMs}ms before next model attempt...`); - await new Promise((resolve) => setTimeout(resolve, sleepMs)); - } - } - } - throw lastError || new Error("Se rebasaron todos los reintentos"); - } - - // Scan Plant Endpoint - app.post("/api/scan-plant", async (req, res) => { - const { base64Image, mimeType, isPresetSeed, presetIndex, targetElement } = req.body; - - // Guardar imagen en src/Imagenes de inmediato si se provee - let savedImagePath = ""; - if (base64Image && /^data:image\/\w+;base64,/.test(base64Image)) { - try { - const parts = base64Image.split(";base64,"); - const cleanBase = parts.length > 1 ? parts[1] : base64Image; - const buffer = Buffer.from(cleanBase, "base64"); - - let ext = "jpg"; - if (mimeType) { - const mParts = mimeType.split("/"); - if (mParts.length > 1) ext = mParts[1]; - } else { - const match = base64Image.match(/^data:image\/([a-zA-Z0-9+.-]+);base64,/i); - if (match) ext = match[1]; - } - - // Normalizar extensiones comunes - ext = ext.toLowerCase(); - if (ext === "jpeg") ext = "jpg"; - else if (ext === "svg+xml") ext = "svg"; - - const dirPath = path.join(process.cwd(), "src", "Imagenes"); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } - - const fileName = `scan-${Date.now()}.${ext}`; - const filePath = path.join(dirPath, fileName); - fs.writeFileSync(filePath, buffer); - console.log(`📸 Imagen de escaneo guardada exitosamente en: ${filePath}`); - - savedImagePath = `/src/Imagenes/${fileName}`; - } catch (err) { - console.error("Error al guardar la imagen en src/Imagenes:", err); - } - } - - // Local quick sandbox preview presets - if (isPresetSeed) { - const idx = Number(presetIndex) >= 0 && Number(presetIndex) < PRESETS_BOTANICAL.length ? Number(presetIndex) : 0; - const item = PRESETS_BOTANICAL[idx]; - return res.json({ - success: true, - data: item, - method: "Pregenerado", - }); - } - - // Direct element override or selective mock pathway - if (targetElement && targetElement !== "Auto-detectar") { - const normalizedTarget = targetElement.toLowerCase() - .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // remove accents (e.g. raíz -> raiz) - .replace("clorofilia", "clorofila"); - - const matchedPreset = PRESETS_BOTANICAL.find(p => { - const pElement = (p.detectedElement || "").toLowerCase() - .normalize("NFD").replace(/[\u0300-\u036f]/g, "") - .replace("clorofilia", "clorofila"); - return pElement === normalizedTarget; - }); - - if (matchedPreset) { - console.log(`🎯 [Filtro Óptico] Retornando preset pre-integrado exacto para la estructura: ${targetElement}`); - return res.json({ - success: true, - data: { - ...matchedPreset, - image: savedImagePath || base64Image || matchedPreset.image, - detectedElement: targetElement // Preserve the exact title requested by the user - }, - method: "Análisis Óptico Directo", - }); - } - } - - const client = getGeminiClient(); - if (!client) { - console.log("Fallback modo simulación por falta de API Key."); - let selectedPreset = PRESETS_BOTANICAL[Math.floor(Math.random() * PRESETS_BOTANICAL.length)]; - - // If a targetElement was requested, try to find a match - if (targetElement && targetElement !== "Auto-detectar") { - const normalizedTarget = targetElement.toLowerCase() - .normalize("NFD").replace(/[\u0300-\u036f]/g, "") - .replace("clorofilia", "clorofila"); - - const found = PRESETS_BOTANICAL.find(p => { - const pElement = (p.detectedElement || "").toLowerCase() - .normalize("NFD").replace(/[\u0300-\u036f]/g, "") - .replace("clorofilia", "clorofila"); - return pElement === normalizedTarget; - }); - if (found) { - selectedPreset = found; - } - } - - const item = { ...selectedPreset }; - item.image = savedImagePath || getFallbackImageByPlantName(item.name); - return res.json({ - success: true, - data: item, - method: "Respaldo Local", - warning: "GEMINI_API_KEY no configurada o no válida. Cargada ficha botánica clasificada para " + (item.detectedElement || "Flora") - }); - } - - if (!base64Image) { - return res.status(400).json({ error: "Falta el archivo de imagen base64." }); - } - - try { - const cleanBase64 = base64Image.replace(/^data:image\/\w+;base64,/, ""); - const imgPart = { - inlineData: { - mimeType: mimeType || "image/jpeg", - data: cleanBase64, - }, - }; - - const textPart = { - text: "Analiza y examina detalladamente esta imagen vegetal. Identifica rigurosamente tanto la planta como su estructura o elemento visible principal. Devuelve estrictamente un objeto JSON en español que cumpla con el esquema requerido, asegurando que 'detectedElement' corresponda exactamente a uno de estos once términos según corresponda al aspecto visible en la imagen." + - (targetElement && targetElement !== "Auto-detectar" ? ` Nota especial: Enfoca prioritariamente la identificación en la estructura vegetal clasificada como: "${targetElement}".` : ""), - }; - - const responseSchema = { - type: Type.OBJECT, - properties: { - name: { type: Type.STRING, description: "Nombre común de la planta en español" }, - scientificName: { type: Type.STRING, description: "Nombre científico en latín" }, - origin: { type: Type.STRING, description: "Origen geográfico de la planta" }, - uses: { type: Type.STRING, description: "Para qué sirve y sus principales utilidades (comestible, medicinal, etc.) en español" }, - description: { type: Type.STRING, description: "Breve descripción botánica clara y cautivadora en español" }, - difficulty: { type: Type.STRING, description: "Dificultad de cultivo recomendado: 'Fácil', 'Moderado' o 'Difícil'" }, - suggestedPriceSol: { type: Type.NUMBER, description: "Precio sugerido de venta en SOL por ración (entre 0.01 y 0.1)" }, - suggestedPriceUsdc: { type: Type.NUMBER, description: "Precio sugerido en USDC (entre 0.5 y 3.0)" }, - suggestedPriceUsdt: { type: Type.NUMBER, description: "Precio sugerido en USDT (entre 0.5 y 3.0)" }, - category: { type: Type.STRING, description: "Categoría de cultivo: 'Hortalizas', 'Medicinales', 'Frutas', 'Hierbas' o 'Otro'" }, - watering: { type: Type.STRING, description: "Consejos de riego específicos para esta planta en español" }, - sunlight: { type: Type.STRING, description: "Requerimientos de luz solar y clima y exposición ideales en español" }, - idealSowingSeason: { type: Type.STRING, description: "Temporada o época ideal del año recomendada para sembrar en español" }, - harvestTimeDays: { type: Type.STRING, description: "Tiempo estimado (días, semanas, o meses) hasta ver la primera cosecha útil en español" }, - soilType: { type: Type.STRING, description: "Tipo de suelo, sustrato o tierra ideales para el crecimiento en español" }, - phRecommended: { type: Type.STRING, description: "Nivel de pH del suelo recomendado u óptimo" }, - companionPlants: { type: Type.STRING, description: "Plantas compañeras ideales que benefician su crecimiento en español" }, - pestPrevention: { type: Type.STRING, description: "Métodos ecológicos o remedios caseros para prevenir sus plagas habituales en español" }, - detectedElement: { type: Type.STRING, description: "El elemento o estructura vegetal detectado principalmente en la foto. Debe ser estrictamente uno de los siguientes: 'Plantas', 'Frutas', 'Frutos', 'Hojas', 'Clorofila', 'Raíz', 'Tallo', 'Flor', 'Semilla', 'Savia', 'Estomas'." }, - }, - required: [ - "name", - "scientificName", - "origin", - "uses", - "description", - "difficulty", - "suggestedPriceSol", - "suggestedPriceUsdc", - "suggestedPriceUsdt", - "category", - "watering", - "sunlight", - "idealSowingSeason", - "harvestTimeDays", - "soilType", - "phRecommended", - "companionPlants", - "pestPrevention", - "detectedElement", - ], - }; - - const response = await generateBotanicalContentWithRetry(client, imgPart, textPart, responseSchema); - - if (response && response.text) { - const parsedData = JSON.parse(response.text.trim()); - // Preserve user scanned local image path when available, otherwise fall back to Unsplash - parsedData.image = savedImagePath || getFallbackImageByPlantName(parsedData.name); - res.json({ - success: true, - data: parsedData, - method: "Gemini AI", - }); - } else { - throw new Error("No text response from Gemini"); - } - } catch (error: any) { - console.log("ℹ️ [Gemini Status] Nota: Las peticiones a la API de Gemini están muy congestionadas en este momento. Se ha activado la ficha botánica pre-integrada del invernadero local de forma automática."); - const randomIndex = Math.floor(Math.random() * PRESETS_BOTANICAL.length); - const item = { ...PRESETS_BOTANICAL[randomIndex] }; - // Always assign savedImagePath if available, then Unsplash fallback, never the huge base64 - item.image = savedImagePath || getFallbackImageByPlantName(item.name); - res.json({ - success: true, - data: item, - method: "Simulado", - error: error.message || "Servicio temporalmente congestionado", - }); - } - }); - - // Vite middleware setup - if (process.env.NODE_ENV !== "production") { - const vite = await createViteServer({ - server: { middlewareMode: true }, - appType: "spa", - }); - app.use(vite.middlewares); - } else { - const distPath = path.join(process.cwd(), "dist"); - app.use(express.static(distPath)); - app.get("*", (req, res) => { - res.sendFile(path.join(distPath, "index.html")); - }); - } - - if (!process.env.VERCEL) { - app.listen(REAL_PORT, "0.0.0.0", () => { - console.log(`🚀 Servidor full-stack corriendo en http://localhost:${REAL_PORT}`); - }); - } -} - -startServer(); +export { app } from "./api/app.js"; diff --git a/src/components/PlantScanner.tsx b/src/components/PlantScanner.tsx index 2976a37..c38acb2 100644 --- a/src/components/PlantScanner.tsx +++ b/src/components/PlantScanner.tsx @@ -52,6 +52,9 @@ export const PlantScanner: React.FC = ({ onCropIdentified }) }), }); const resultData = await res.json(); + if (!res.ok) { + throw new Error(resultData.error || `Error HTTP ${res.status} analizando la imagen`); + } if (resultData.success && resultData.data) { const cropRes: Crop = { @@ -95,14 +98,14 @@ export const PlantScanner: React.FC = ({ onCropIdentified }) } }; - const resizeImageBase64 = (base64Str: string, mimeType: string = "image/jpeg"): Promise => { - return new Promise((resolve) => { + const resizeImageBase64 = (base64Str: string): Promise => { + return new Promise((resolve, reject) => { const img = new Image(); img.src = base64Str; img.onload = () => { const canvas = document.createElement("canvas"); - const maxWidth = 320; - const maxHeight = 320; + const maxWidth = 256; + const maxHeight = 256; let width = img.width; let height = img.height; @@ -123,13 +126,13 @@ export const PlantScanner: React.FC = ({ onCropIdentified }) const ctx = canvas.getContext("2d"); if (ctx) { ctx.drawImage(img, 0, 0, width, height); - resolve(canvas.toDataURL(mimeType, 0.75)); + resolve(canvas.toDataURL("image/jpeg", 0.68)); } else { - resolve(base64Str); + reject(new Error("No se pudo preparar la imagen para el análisis.")); } }; img.onerror = () => { - resolve(base64Str); + reject(new Error("Formato de imagen no soportado por el navegador.")); }; }); }; @@ -144,13 +147,17 @@ export const PlantScanner: React.FC = ({ onCropIdentified }) const reader = new FileReader(); reader.onload = async () => { const parentBase64 = reader.result as string; - const targetMimeType = file.type || "image/jpeg"; - const compressedBase64 = await resizeImageBase64(parentBase64, targetMimeType); - triggerScanApi({ - base64Image: compressedBase64, - mimeType: targetMimeType, - isPresetSeed: false, - }); + try { + const compressedBase64 = await resizeImageBase64(parentBase64); + triggerScanApi({ + base64Image: compressedBase64, + mimeType: "image/jpeg", + isPresetSeed: false, + }); + } catch (err: any) { + console.error(err); + alert(err.message || "No se pudo preparar la imagen para el análisis."); + } }; reader.readAsDataURL(file); }; From 8b0d2581c67a6a5bf28d9f0f577af732118ed2f5 Mon Sep 17 00:00:00 2001 From: Juan Salazar Date: Wed, 17 Jun 2026 01:06:24 -0400 Subject: [PATCH 6/6] feat: add Backpack wallet support and update image processing - Integrate `@solana/wallet-adapter-backpack` - Adjust image resizing constraints for better performance - Remove unused Supabase storage logic from server --- .env.example | 1 - api/app.ts | 1067 ---------------------------- api/index.ts | 2 +- package-lock.json | 686 +++++++++++++++++- package.json | 1 + server.ts | 274 ++----- src/components/PlantScanner.tsx | 35 +- src/components/WalletHub.tsx | 27 +- temp_phantom/adapter.ts | 307 ++++++++ temp_phantom/index.ts | 1 + temp_phantom_pkg/CHANGELOG.md | 90 +++ temp_phantom_pkg/LICENSE | 202 ++++++ temp_phantom_pkg/README.md | 5 + temp_phantom_pkg/package.json | 44 ++ temp_phantom_pkg/src/adapter.ts | 307 ++++++++ temp_phantom_pkg/src/index.ts | 1 + temp_phantom_pkg/tsconfig.cjs.json | 7 + temp_phantom_pkg/tsconfig.esm.json | 8 + temp_phantom_pkg/tsconfig.json | 11 + 19 files changed, 1767 insertions(+), 1309 deletions(-) delete mode 100644 api/app.ts create mode 100644 temp_phantom/adapter.ts create mode 100644 temp_phantom/index.ts create mode 100644 temp_phantom_pkg/CHANGELOG.md create mode 100644 temp_phantom_pkg/LICENSE create mode 100644 temp_phantom_pkg/README.md create mode 100644 temp_phantom_pkg/package.json create mode 100644 temp_phantom_pkg/src/adapter.ts create mode 100644 temp_phantom_pkg/src/index.ts create mode 100644 temp_phantom_pkg/tsconfig.cjs.json create mode 100644 temp_phantom_pkg/tsconfig.esm.json create mode 100644 temp_phantom_pkg/tsconfig.json diff --git a/.env.example b/.env.example index 96e1ff2..5092938 100644 --- a/.env.example +++ b/.env.example @@ -11,4 +11,3 @@ APP_URL="MY_APP_URL" # Supabase Credentials VITE_SUPABASE_URL="https://klaompnbmjufvhjkeeno.supabase.co" VITE_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtsYW9tcG5ibWp1ZnZoamtlZW5vIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODE1NjY5ODEsImV4cCI6MjA5NzE0Mjk4MX0.udKgeFZLsVzXvSU0oqR0F3_J7EDCA1g7MxF00l8LEEc" -SUPABASE_STORAGE_BUCKET="imagenes" diff --git a/api/app.ts b/api/app.ts deleted file mode 100644 index 910c29d..0000000 --- a/api/app.ts +++ /dev/null @@ -1,1067 +0,0 @@ -import express from "express"; -import path from "path"; -import { GoogleGenAI, Type } from "@google/genai"; -import dotenv from "dotenv"; -import { createServer as createViteServer } from "vite"; -import fs from "fs"; -import { createClient } from "@supabase/supabase-js"; - -dotenv.config(); - -// Supabase configuration -const rawSupabaseUrl = "https://klaompnbmjufvhjkeeno.supabase.co"; -const supabaseUrl = rawSupabaseUrl.replace(/\/rest\/v1\/?$/, ''); -const supabaseAnonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtsYW9tcG5ibWp1ZnZoamtlZW5vIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODE1NjY5ODEsImV4cCI6MjA5NzE0Mjk4MX0.udKgeFZLsVzXvSU0oqR0F3_J7EDCA1g7MxF00l8LEEc"; -const supabase = createClient(supabaseUrl, supabaseAnonKey); -const SUPABASE_QUERY_TIMEOUT_MS = 3000; -const PLANT_IMAGES_BUCKET = process.env.SUPABASE_STORAGE_BUCKET || "imagenes"; - -const normalizeImageExtension = (mimeType?: string) => { - const rawExt = mimeType?.split("/")?.[1]?.toLowerCase() || "jpg"; - if (rawExt === "jpeg") return "jpg"; - if (rawExt === "svg+xml") return "svg"; - return rawExt.replace(/[^a-z0-9]/g, "") || "jpg"; -}; - -const uploadScanImageToSupabase = async ( - base64Image: string, - mimeType?: string -): Promise => { - const parts = base64Image.split(";base64,"); - const cleanBase = parts.length > 1 ? parts[1] : base64Image; - const buffer = Buffer.from(cleanBase, "base64"); - const contentType = mimeType || base64Image.match(/^data:([^;]+);base64,/i)?.[1] || "image/jpeg"; - const ext = normalizeImageExtension(contentType); - const objectPath = `scans/scan-${Date.now()}-${Math.random().toString(36).slice(2, 10)}.${ext}`; - - const { error } = await supabase.storage - .from(PLANT_IMAGES_BUCKET) - .upload(objectPath, buffer, { - contentType, - upsert: false, - }); - - if (error) { - throw error; - } - - const { data } = supabase.storage - .from(PLANT_IMAGES_BUCKET) - .getPublicUrl(objectPath); - - return data.publicUrl; -}; - -const withTimeout = async ( - promise: Promise, - timeoutMs: number, - errorMessage: string -): Promise => { - let timeout: NodeJS.Timeout | undefined; - const timeoutPromise = new Promise((_, reject) => { - timeout = setTimeout(() => reject(new Error(errorMessage)), timeoutMs); - }); - - try { - return await Promise.race([promise, timeoutPromise]); - } finally { - if (timeout) { - clearTimeout(timeout); - } - } -}; - -// Initialize Gemini Client Lazily/Safely -let aiClient: GoogleGenAI | null = null; -const getGeminiClient = (): GoogleGenAI | null => { - const apiKey = process.env.GEMINI_API_KEY; - if (!apiKey || apiKey === "MY_GEMINI_API_KEY") { - console.warn("⚠️ Advertencia: GEMINI_API_KEY no está configurada o usa el marcador por defecto."); - return null; - } - if (!aiClient) { - try { - aiClient = new GoogleGenAI({ - apiKey: apiKey, - httpOptions: { - headers: { - "User-Agent": "aistudio-build", - }, - }, - }); - console.log("✅ Cliente Gemini configurado exitosamente."); - } catch (e) { - console.error("❌ Error al instanciar el cliente Gemini:", e); - } - } - return aiClient; -}; - -// Mock standard database of crop results to fallback in case of errors / no API key -const PRESETS_BOTANICAL: Array<{ - name: string; - scientificName: string; - origin: string; - uses: string; - description: string; - difficulty: "Fácil" | "Moderado" | "Difícil"; - suggestedPriceSol: number; - suggestedPriceUsdc: number; - suggestedPriceUsdt: number; - category: "Hortalizas" | "Medicinales" | "Frutas" | "Hierbas" | "Otro"; - watering: string; - sunlight: string; - idealSowingSeason: string; - harvestTimeDays: string; - soilType: string; - phRecommended: string; - companionPlants: string; - pestPrevention: string; - detectedElement?: string; - image: string; -}> = [ - { - name: "Rábano Fast-Grow", - scientificName: "Raphanus sativus", - origin: "Eurasia", - detectedElement: "Raíz", - uses: "Alimenticio: Consumo en ensaladas, aporta textura crujiente y toque picante. Alto en vitamina C y fibra.", - description: "Pequeña raíz globosa de color rojo intenso con pulpa blanca, crujiente y refrescante.", - difficulty: "Fácil", - suggestedPriceSol: 0.015, - suggestedPriceUsdc: 0.75, - suggestedPriceUsdt: 0.75, - category: "Hortalizas", - watering: "Riego regular y uniforme para evitar que la raíz se agriete o se ponga demasiado picante.", - sunlight: "Sol pleno o semisombra.", - idealSowingSeason: "Primavera, Otoño y finales de Verano.", - harvestTimeDays: "21 a 30 días", - soilType: "Suelo suelto, ligero, bien drenado y rico en materia orgánica.", - phRecommended: "6.0 a 7.0.", - companionPlants: "Espinaca, lechuga y guisantes. Ayuda a dispersar plagas comunes.", - pestPrevention: "Proteger con malla anti-insectos en las primeras etapas y vigilar el escarabajo pulga.", - image: "https://images.unsplash.com/photo-1590005354167-6da97870c913?auto=format&fit=crop&q=80&w=300" - }, - { - name: "Menta Piperita", - scientificName: "Mentha x piperita", - origin: "Europa", - uses: "Medicinal: Alivia espasmos, digestiones difíciles e infusión relajante. Culinario: Aromatizante de licores y postres.", - description: "Planta herbácea perenne muy aromática, de tallos cuadrangulares rojizos y hojas dentadas con intenso olor a mentol.", - difficulty: "Fácil", - suggestedPriceSol: 0.015, - suggestedPriceUsdc: 0.75, - suggestedPriceUsdt: 0.75, - category: "Medicinales", - watering: "Riego abundante y regular. Prefiere suelos húmedos de forma continua pero con desague óptimo.", - sunlight: "Sombra parcial o semisombra. Prefiere luz tamizada indirecta.", - idealSowingSeason: "Principios de Primavera u Otoño (es altamente invasiva, preferir cultivo en macetas separadas).", - harvestTimeDays: "60 a 70 días tras la siembra.", - soilType: "Suelo arcilloso o suelto pero muy rico en materia orgánica con alta capacidad de retención de humedad.", - phRecommended: "6.5 a 7.0.", - companionPlants: "Repollo, coliflor y lechuga. Repele plagas de orugas. ¡No plantar cerca de manzanilla o perejil!", - pestPrevention: "Podar a ras de suelo en Otoño para un resurgir vigoroso en Primavera. Controlar caracoles manualmente.", - detectedElement: "Hojas", - image: "https://images.unsplash.com/photo-1608686207856-001b95cf60ca?auto=format&fit=crop&q=80&w=300" - }, - { - name: "Frutilla Silvestre", - scientificName: "Fragaria vesca", - origin: "Eurasia", - uses: "Alimenticio: Consumo fresco, mermeladas y helados. Rica en antioxidantes, ácido fólico y vitamina C.", - description: "Planta rastrera perenne que produce estolones, hojas trifoliadas dentadas y pequeños frutos rojos muy fragantes y dulces.", - difficulty: "Moderado", - suggestedPriceSol: 0.06, - suggestedPriceUsdc: 2.50, - suggestedPriceUsdt: 2.50, - category: "Frutas", - watering: "Moderado y constante. El método de goteo es excelente para proteger la corona de la planta de pudriciones.", - sunlight: "Pleno sol para máxima fructificación y sabor dulce, pero tolera algo de semisombra.", - idealSowingSeason: "A finales de Otoño o principios de Primavera.", - harvestTimeDays: "90 a 120 días tras la plantación.", - soilType: "Suelo arenoso rico en humus, bien acolchado con paja seca para evitar que las frutillas toquen la tierra.", - phRecommended: "5.8 a 6.2.", - companionPlants: "Espinacas, cebollas y borraja. Evitar plantar cerca de otras solanáceas o brassicas.", - pestPrevention: "Abonar con compost enriquecido en potasio. Vigilar la aparición de pulgones rociando jabón potásico.", - detectedElement: "Frutas", - image: "https://images.unsplash.com/photo-1464965911861-746a04b4bca6?auto=format&fit=crop&q=80&w=300" - }, - { - name: "Helecho Espada Sol", - scientificName: "Nephrolepis exaltata", - origin: "Zonas tropicales de América y Asia", - uses: "Ornamental: Purifica el aire interior capturando formaldehído. Excelente para colgar en canastos.", - description: "Espectacular planta perenne de frondas arqueadas y plumosas, que aporta un follaje verde y tupido sumamente decorativo.", - difficulty: "Fácil", - suggestedPriceSol: 0.03, - suggestedPriceUsdc: 1.20, - suggestedPriceUsdt: 1.20, - category: "Otro", - watering: "Frecuente para mantener el sustrato constantemente húmedo pero sin encharcar la maceta.", - sunlight: "Luz indirecta brillante o semisombra. Evitar el sol directo que quema sus delicadas frondas.", - idealSowingSeason: "Primavera u Otoño húmedo.", - harvestTimeDays: "Crecimiento constante todo el año", - soilType: "Sustrato a base de turba, poroso, rico en nutrientes y con excelente drenaje de agua.", - phRecommended: "5.5 a 6.0.", - companionPlants: "Orquídeas, potos y otras plantas que disfrutan de alta humedad ambiental.", - pestPrevention: "Mantener alta humedad pulverizando agua regularmente para ahuyentar a la araña roja.", - detectedElement: "Plantas", - image: "https://images.unsplash.com/photo-1545241047-6083a3684587?auto=format&fit=crop&q=80&w=300" - }, - { - name: "Pimiento Dulce", - scientificName: "Capsicum annuum", - origin: "Mesoamérica", - uses: "Culinario: Consumo fresco, asado o frito. Fuente excelente de vitaminas A y C esenciales.", - description: "Fruto hueco de paredes carnosas y jugosas que cambia de verde a colores brillantes rojos, amarillos o naranjas al madurar.", - difficulty: "Moderado", - suggestedPriceSol: 0.04, - suggestedPriceUsdc: 1.50, - suggestedPriceUsdt: 1.50, - category: "Hortalizas", - watering: "Humedad regular. Evitar estrés hídrico durante la floración y el desarrollo del fruto.", - sunlight: "Pleno sol constante y temperaturas cálidas para un óptimo cuajado de frutos.", - idealSowingSeason: "Finales de Invierno o principios de Primavera.", - harvestTimeDays: "70 a 90 días", - soilType: "Suelo fértil, profundo, rico en materia orgánica y con excelente desague.", - phRecommended: "6.0 a 6.8.", - companionPlants: "Albahaca, tomate, cebollas y cilantro de huerto.", - pestPrevention: "Uso de trampas cromáticas amarillas y pulverización preventiva con jabón potásico para pulgones.", - detectedElement: "Frutos", - image: "https://images.unsplash.com/photo-1563861826100-9cb868fdcd1e?auto=format&fit=crop&q=80&w=300" - }, - { - name: "Espinaca Clorofílica", - scientificName: "Spinacia oleracea", - origin: "Persia antigua", - uses: "Alimenticio: Gran aporte de hierro, ácido fólico y clorofila depurativa. Se consume fresca o cocida.", - description: "Hojas carnosas de color verde oscuro brillante dispuestas en roseta, ricas en clorofila y fitonutrientes saludables.", - difficulty: "Fácil", - suggestedPriceSol: 0.02, - suggestedPriceUsdc: 1.00, - suggestedPriceUsdt: 1.00, - category: "Hortalizas", - watering: "Riego regular y moderado, manteniendo la tierra uniformemente húmeda pero nunca pesada.", - sunlight: "Semisombra o sol parcial. La luz solar excesiva puede acelerar la producción prematura de semillas.", - idealSowingSeason: "Otoño y Primavera temprana para disfrutar del clima fresco.", - harvestTimeDays: "40 a 50 días", - soilType: "Suelos pesados o francos, muy ricos en nitrógeno orgánico.", - phRecommended: "6.5 a 7.5.", - companionPlants: "Frutillas, habas, guisantes y repollo.", - pestPrevention: "Remover malezas manualmente y controlar orugas con tratamientos con Bacillus thuringiensis de ser necesario.", - detectedElement: "Clorofila", - image: "https://images.unsplash.com/photo-1576045057995-568f588f82fb?auto=format&fit=crop&q=80&w=300" - }, - { - name: "Apio de Sacramento", - scientificName: "Apium graveolens", - origin: "Zonas mediterráneas", - uses: "Culinario: Cocción en caldos, sopas o consumo de tallos crujientes en ensaladas y jugos desintoxicantes.", - description: "Planta con gruesos tallos acanalados y fibrosos que forman una corona compacta de color verde pálido.", - difficulty: "Difícil", - suggestedPriceSol: 0.035, - suggestedPriceUsdc: 1.40, - suggestedPriceUsdt: 1.40, - category: "Hortalizas", - watering: "Exigente en riego continuo. Necesita humedad constante y un sustrato rico en nutrientes.", - sunlight: "Semisombra o pleno sol con protección frente a vientos secos.", - idealSowingSeason: "Primavera para cosecha en Otoño.", - harvestTimeDays: "120 a 150 días", - soilType: "Suelo de huerta pesado o arcilloso, muy fértil y retenedor de agua.", - phRecommended: "6.0 a 7.0.", - companionPlants: "Cebolla, ajo, coliflor y tomates.", - pestPrevention: "Abonar intensivamente y pulverizar decocciones de cola de caballo para prevenir hongos.", - detectedElement: "Tallo", - image: "https://images.unsplash.com/photo-1610970881699-44a5587cabec?auto=format&fit=crop&q=80&w=300" - }, - { - name: "Manzanilla de la Reina", - scientificName: "Matricaria chamomilla", - origin: "Europa y Asia templada", - uses: "Infusión medicinal: Aliviador gástrico y sedante natural. Antiinflamatorio ocular.", - description: "Planta herbácea con pequeñas flores similares a margaritas, que emiten una fragancia dulce y relajante.", - difficulty: "Fácil", - suggestedPriceSol: 0.02, - suggestedPriceUsdc: 0.90, - suggestedPriceUsdt: 0.90, - category: "Medicinales", - watering: "Moderado. Soporta muy bien periodos cortos de sequía una vez establecida.", - sunlight: "Pleno sol para maximizar la concentración de aceites esenciales curativos.", - idealSowingSeason: "Otoño o Primavera directa a suelo.", - harvestTimeDays: "60 a 80 días", - soilType: "Suelo liviano, arenoso y no demasiado fértil.", - phRecommended: "6.0 a 7.2.", - companionPlants: "Cebollas, coles y trigo. Mejora el sabor de vecinas aromáticas.", - pestPrevention: "Evitar suelos pesados que propicien pudrición de raíz. Pulverizar agua con ajo si surge pulgón.", - detectedElement: "Flor", - image: "https://images.unsplash.com/photo-1588145293284-cd9d282e4e13?auto=format&fit=crop&q=80&w=300" - }, - { - name: "Girasol de Oro", - scientificName: "Helianthus annuus", - origin: "Norteamérica", - uses: "Culinario: Extracción de aceite de alta cocina y snack saludable de semillas crujientes de girasol.", - description: "Hermosa e imponente inflorescencia amarilla gigante que sigue la trayectoria solar diaria, albergando cientos de ricas semillas.", - difficulty: "Fácil", - suggestedPriceSol: 0.025, - suggestedPriceUsdc: 1.10, - suggestedPriceUsdt: 1.10, - category: "Hierbas", - watering: "Moderado pero profundo. Tolera sequía moderada gracias a su raíz pivotante larga.", - sunlight: "Sol directo absoluto (mínimo de 6 a 8 horas diarias prescritas).", - idealSowingSeason: "Mediados de Primavera, tras desaparecer las heladas.", - harvestTimeDays: "80 a 110 días", - soilType: "Suelo suelto y profundo que permita la libre extensión de su raíz.", - phRecommended: "6.0 a 7.5.", - companionPlants: "Maíz, pepinos y calabazas montantes.", - pestPrevention: "Proteger las flores maduras de las aves con mallas finas si se desea conservar las semillas intactas.", - detectedElement: "Semilla", - image: "https://images.unsplash.com/photo-1595855759920-86582396756a?auto=format&fit=crop&q=80&w=300" - }, - { - name: "Sangre de Dragón", - scientificName: "Croton lechleri", - origin: "Amazonía americana", - uses: "Medicinal: Cicatrizante ultrapotente de heridas y protector celular cutáneo ante patógenos.", - description: "Árbol tropical cuya corteza, al ser raspada o cortada, secreta una savia rojiza espesa rica en taspina.", - difficulty: "Difícil", - suggestedPriceSol: 0.09, - suggestedPriceUsdc: 3.50, - suggestedPriceUsdt: 3.50, - category: "Medicinales", - watering: "Riego espaciado simulando las abundantes lluvias de selva.", - sunlight: "Cálido y húmedo con sol directo filtrado o tamizado.", - idealSowingSeason: "Estación lluviosa tropical.", - harvestTimeDays: "Varios años para el desarrollo óptimo de su tronco.", - soilType: "Suelo de bosque húmedo con abundante humus ácido.", - phRecommended: "5.0 a 6.0.", - companionPlants: "Helechos tropicales, bromelias y cacao de monte.", - pestPrevention: "Tratar plagas fúngicas de hojas tiernas con macerado purificante de ajo y jengibre.", - detectedElement: "Savia", - image: "https://images.unsplash.com/photo-1502082553048-f009c37129b9?auto=format&fit=crop&q=80&w=300" - }, - { - name: "Hiedra de Pared", - scientificName: "Hedera helix", - origin: "Europa, Asia y África", - uses: "Funcional: Cubrimiento térmico estético de muros e investigación botánica de estomas activos en hojas perennes.", - description: "Planta trepadora perenne con hojas verdes brillantes coriáceas muy estudiada para revelar estomas microscópicos.", - difficulty: "Fácil", - suggestedPriceSol: 0.02, - suggestedPriceUsdc: 0.85, - suggestedPriceUsdt: 0.85, - category: "Otro", - watering: "Moderado. Dejar secar ligeramente la capa superior de tierra antes del siguiente riego.", - sunlight: "Prefiere sombra o luz tamizada.", - idealSowingSeason: "Primavera u Otoño en climas templados.", - harvestTimeDays: "Crecimiento constante rápido.", - soilType: "Cualquier suelo bien drenado, tolera suelos pobres en nutrientes.", - phRecommended: "6.0 a 7.5.", - companionPlants: "Cualquier planta de sotobosque y helechos.", - pestPrevention: "Humedecer el follaje para prevenir ácaros y cochinillas en épocas de calor extremo.", - detectedElement: "Estomas", - image: "https://images.unsplash.com/photo-1508739773434-c26b3d09e071?auto=format&fit=crop&q=80&w=300" - } -]; - -// Memory state to support persistence over developer server session -interface ServerCrop { - id: string; - name: string; - scientificName: string; - origin: string; - uses: string; - description: string; - difficulty: "Fácil" | "Moderado" | "Difícil"; - image: string; - priceSol: number; - priceUsdc: number; - priceUsdt: number; - stock: number; - isForSale: boolean; - category: "Hortalizas" | "Medicinales" | "Frutas" | "Hierbas" | "Otro"; - watering?: string; - sunlight?: string; - idealSowingSeason?: string; - harvestTimeDays?: string; - soilType?: string; - phRecommended?: string; - companionPlants?: string; - pestPrevention?: string; - detectedElement?: string; -} - -interface ServerLedgerLog { - id: string; - timestamp: string; - cropName: string; - quantity: number; - amount: number; - currency: "SOL" | "USDC" | "USDT"; - signature: string; - status: "EXITOSO" | "PENDIENTE" | "FALLIDO"; -} - -const CROPS_FILE = path.join(process.cwd(), "crops_data.json"); -const LEDGER_FILE = path.join(process.cwd(), "ledger_data.json"); -const VOLUME_FILE = path.join(process.cwd(), "volume_data.json"); - -// Helper function to resolve high-quality Unsplash image by plant/crop name -function getFallbackImageByPlantName(name: string): string { - const lowercase = (name || "").toLowerCase(); - - if (lowercase.includes("cafeto") || lowercase.includes("café") || lowercase.includes("coffee")) { - return "https://images.unsplash.com/photo-1514432324607-a09d9b4aefdd?auto=format&fit=crop&q=80&w=300"; - } - if (lowercase.includes("caña") || lowercase.includes("sugarcane")) { - return "https://images.unsplash.com/photo-1543257580-7269da773bf5?auto=format&fit=crop&q=80&w=300"; - } - if (lowercase.includes("plátano") || lowercase.includes("platano") || lowercase.includes("banano") || lowercase.includes("banana") || lowercase.includes("guineo")) { - return "https://images.unsplash.com/photo-1571771894821-ce9b6c11b08e?auto=format&fit=crop&q=80&w=300"; - } - if (lowercase.includes("aguacate") || lowercase.includes("avocado") || lowercase.includes("palta")) { - return "https://images.unsplash.com/photo-1523049673857-eb18f1d7b578?auto=format&fit=crop&q=80&w=300"; - } - if (lowercase.includes("naranja") || lowercase.includes("orange") || lowercase.includes("cítrico") || lowercase.includes("citrico")) { - return "https://images.unsplash.com/photo-1547514701-42782101795e?auto=format&fit=crop&q=80&w=300"; - } - if (lowercase.includes("rabano") || lowercase.includes("radish")) { - return "https://images.unsplash.com/photo-1590005354167-6da97870c913?auto=format&fit=crop&q=80&w=300"; - } - if (lowercase.includes("menta") || lowercase.includes("mint") || lowercase.includes("piperita")) { - return "https://images.unsplash.com/photo-1608686207856-001b95cf60ca?auto=format&fit=crop&q=80&w=300"; - } - if (lowercase.includes("frutilla") || lowercase.includes("fresa") || lowercase.includes("strawberry")) { - return "https://images.unsplash.com/photo-1464965911861-746a04b4bca6?auto=format&fit=crop&q=80&w=300"; - } - - return `https://images.unsplash.com/photo-1466692476868-aef1dfb1e735?auto=format&fit=crop&q=80&w=300`; -} - -function loadCrops(): ServerCrop[] { - try { - if (fs.existsSync(CROPS_FILE)) { - const data = fs.readFileSync(CROPS_FILE, "utf-8"); - const list: ServerCrop[] = JSON.parse(data); - let updated = false; - const sanitized = list.map(c => { - // Purge base64 / excessively large image strings that cause QuotaExceededError - if (c.image && (c.image.startsWith("data:") || c.image.length > 1000)) { - c.image = getFallbackImageByPlantName(c.name); - updated = true; - } - return c; - }); - if (updated) { - saveCrops(sanitized); - } - return sanitized; - } - } catch (e) { - console.error("Error al cargar crops_data.json:", e); - } - return [ - { - id: "crop-2", - name: "Rábano Fast-Grow", - detectedElement: "Raíz", - scientificName: "Raphanus sativus", - origin: "Eurasia", - uses: "Alimenticio: Consumo en ensaladas, aporta textura crujiente y toque picante. Alto en vitamina C.", - description: "Pequeña raíz globosa de color rojo intenso con pulpa blanca, crujiente y refrescante.", - difficulty: "Fácil", - image: "https://images.unsplash.com/photo-1590005354167-6da97870c913?auto=format&fit=crop&q=80&w=300", - priceSol: 0.015, - priceUsdc: 0.75, - priceUsdt: 0.75, - stock: 5, - isForSale: true, - category: "Hortalizas", - watering: "Riego regular y uniforme para evitar que la raíz se agriete o se ponga demasiado picante.", - sunlight: "Sol pleno o semisombra.", - idealSowingSeason: "Primavera, Otoño.", - harvestTimeDays: "21 a 30 días.", - soilType: "Suelo suelto, ligero, bien drenado.", - phRecommended: "6.0 a 7.0.", - companionPlants: "Espinaca, lechuga y guisantes.", - pestPrevention: "Proteger con malla anti-insectos." - } - ]; -} - -function saveCrops(crops: ServerCrop[]) { - try { - fs.writeFileSync(CROPS_FILE, JSON.stringify(crops, null, 2), "utf-8"); - } catch (e) { - console.error("Error al guardar crops_data.json:", e); - } -} - -function loadLedger(): ServerLedgerLog[] { - try { - if (fs.existsSync(LEDGER_FILE)) { - const data = fs.readFileSync(LEDGER_FILE, "utf-8"); - return JSON.parse(data); - } - } catch (e) { - console.error("Error al cargar ledger_data.json:", e); - } - return [ - { - id: "tx-1", - timestamp: new Date(Date.now() - 30 * 60000).toISOString(), - cropName: "Rábano Fast-Grow", - quantity: 1, - amount: 0.75, - currency: "USDC", - signature: "5R7P37v6y8X9qZd2B1cK3eHgFdSjKa8s9dF2gH1jK3l7s9z2x3c4v5b6n7m8", - status: "EXITOSO", - }, - { - id: "tx-2", - timestamp: new Date(Date.now() - 15 * 60000).toISOString(), - cropName: "Rábano Fast-Grow", - quantity: 1, - amount: 0.75, - currency: "USDT", - signature: "5R2W9qZd2B1cK3eHgFdSjKa8s9dF2gH1jK3l7s9z2x3c4v5b6n7m8tx3k2l19", - status: "EXITOSO", - } - ]; -} - -function saveLedger(ledger: ServerLedgerLog[]) { - try { - fs.writeFileSync(LEDGER_FILE, JSON.stringify(ledger, null, 2), "utf-8"); - } catch (e) { - console.error("Error al guardar ledger_data.json:", e); - } -} - -function loadVolume(): number { - try { - if (fs.existsSync(VOLUME_FILE)) { - const data = fs.readFileSync(VOLUME_FILE, "utf-8"); - return Number(data) || 28.62; - } - } catch (e) { - console.error("Error al cargar volume_data.json:", e); - } - return 28.62; -} - -function saveVolume(volume: number) { - try { - fs.writeFileSync(VOLUME_FILE, String(volume), "utf-8"); - } catch (e) { - console.error("Error al guardar volume_data.json:", e); - } -} - -let activeCrops: ServerCrop[] = loadCrops(); -let paymentLedger: ServerLedgerLog[] = loadLedger(); -let mockVolumenSalesUsd = loadVolume(); - -export const app = express(); - -async function startServer() { - const REAL_PORT = 3000; - - app.use(express.json({ limit: "15mb" })); - app.use(express.urlencoded({ extended: true, limit: "15mb" })); - app.use((err: any, _req: any, res: any, next: any) => { - if (!err) { - return next(); - } - - console.error("Error procesando el cuerpo de la petición:", err); - const status = err.type === "entity.too.large" ? 413 : 400; - return res.status(status).json({ - success: false, - error: status === 413 - ? "La imagen es demasiado grande para procesarla. Intenta con una foto más liviana." - : "No se pudo interpretar la petición de análisis.", - }); - }); - app.use("/src/Imagenes", express.static(path.join(process.cwd(), "src", "Imagenes"))); - app.use("/src/imagenes", express.static(path.join(process.cwd(), "src", "imagenes"))); - - // API Endpoints - app.get("/api/crops", async (req, res) => { - try { - const { data, error } = await withTimeout( - Promise.resolve(supabase.from('crops').select('*')), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout consultando cultivos en Supabase.' - ); - if (error) throw error; - res.json(data || []); - } catch (error: any) { - console.error("Supabase /api/crops GET error:", error.message || error); - res.json(activeCrops); - } - }); - - app.post("/api/crops", async (req, res) => { - const { - id, name, scientificName, origin, uses, description, difficulty, - priceSol, priceUsdc, priceUsdt, stock, isForSale, category, image, - watering, sunlight, idealSowingSeason, harvestTimeDays, soilType, - phRecommended, companionPlants, pestPrevention, detectedElement - } = req.body; - - const newCrop = { - id: id || `crop-${Date.now()}`, - name: name || "Cultivo Desconocido", - scientificName: scientificName || "Incognita", - origin: origin || "Desconocido", - uses: uses || "No especificado", - description: description || "No disponible", - difficulty: (difficulty || "Fácil"), - image: image || "", - priceSol: Number(priceSol) || 0.01, - priceUsdc: Number(priceUsdc) || 0.5, - priceUsdt: Number(priceUsdt) || 0.5, - stock: Number(stock) || 5, - isForSale: isForSale !== undefined ? isForSale : false, - category: category || "Otro", - watering: watering || "", - sunlight: sunlight || "", - idealSowingSeason: idealSowingSeason || "", - harvestTimeDays: harvestTimeDays || "", - soilType: soilType || "", - phRecommended: phRecommended || "", - companionPlants: companionPlants || "", - pestPrevention: pestPrevention || "", - detectedElement: detectedElement || "Plantas" - }; - - try { - // Check if exists - const { data: existing } = await withTimeout( - Promise.resolve(supabase.from('crops').select('id').eq('id', newCrop.id).maybeSingle()), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout verificando cultivo existente en Supabase.' - ); - - if (existing) { - return res.json(newCrop); - } - - const { data, error } = await withTimeout( - Promise.resolve(supabase.from('crops').insert([newCrop]).select()), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout guardando cultivo en Supabase.' - ); - if (error) throw error; - - res.status(201).json(data?.[0] || newCrop); - } catch (error: any) { - console.error("Supabase /api/crops POST error:", JSON.stringify(error, null, 2), error.message); - if (!activeCrops.find(c => c.id === newCrop.id)) { - activeCrops.push(newCrop); - saveCrops(activeCrops); - } - res.status(201).json(newCrop); - } - }); - - app.put("/api/crops/:id", async (req, res) => { - const { id } = req.params; - try { - const { data, error } = await withTimeout( - Promise.resolve(supabase.from('crops').update(req.body).eq('id', id).select()), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout actualizando cultivo en Supabase.' - ); - - if (error) throw error; - - if (data && data.length > 0) { - res.json(data[0]); - } else { - res.status(404).json({ error: "Cultivo no encontrado" }); - } - } catch (error: any) { - console.error("Supabase /api/crops PUT error:", error); - const idx = activeCrops.findIndex(c => c.id === id); - if (idx !== -1) { - activeCrops[idx] = { ...activeCrops[idx], ...req.body }; - saveCrops(activeCrops); - res.json(activeCrops[idx]); - } else { - res.status(404).json({ error: "Cultivo no encontrado localmente" }); - } - } - }); - - app.delete("/api/crops/:id", async (req, res) => { - const { id } = req.params; - try { - const { error } = await withTimeout( - Promise.resolve(supabase.from('crops').delete().eq('id', id)), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout eliminando cultivo en Supabase.' - ); - if (error) throw error; - res.json({ success: true }); - } catch (error: any) { - console.error("Supabase /api/crops DELETE error:", error); - activeCrops = activeCrops.filter(c => c.id !== id); - saveCrops(activeCrops); - res.json({ success: true }); - } - }); - - // Payment Ledger Endpoints - app.get("/api/ledger", async (req, res) => { - try { - const { data: ledgerData, error: ledgerError } = await withTimeout( - Promise.resolve(supabase.from('ledger').select('*').order('timestamp', { ascending: false })), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout consultando ledger en Supabase.' - ); - if (ledgerError) throw ledgerError; - - const { data: volumeData } = await withTimeout( - Promise.resolve(supabase.from('store_metrics').select('totalSalesUsd').eq('id', 'main').maybeSingle()), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout consultando métricas en Supabase.' - ); - - res.json({ - ledger: ledgerData || [], - totalSalesUsd: volumeData?.totalSalesUsd || mockVolumenSalesUsd || 0, - }); - } catch (error: any) { - console.error("Supabase /api/ledger GET error:", error); - res.json({ - ledger: paymentLedger, - totalSalesUsd: mockVolumenSalesUsd - }); - } - }); - - app.post("/api/ledger", async (req, res) => { - const { cropName, quantity, amount, currency, signature, timestamp, id } = req.body; - - const newLog = { - id: id || `tx-${Date.now()}`, - timestamp: timestamp || new Date().toISOString(), - cropName: cropName || "Compra de Cultivo", - quantity: Number(quantity) || 1, - amount: Number(amount) || 0, - currency: currency || "SOL", - signature: signature || Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15), - status: "EXITOSO" as const - }; - - let usdValue = newLog.amount; - if (newLog.currency === "SOL") { - usdValue = newLog.amount * 150.0; - } - - try { - const { error } = await withTimeout( - Promise.resolve(supabase.from('ledger').insert([newLog as any])), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout guardando ledger en Supabase.' - ); - if (error) throw error; - - const newVol = Number((mockVolumenSalesUsd + usdValue).toFixed(2)); - mockVolumenSalesUsd = newVol; - await withTimeout( - Promise.resolve(supabase.from('store_metrics').upsert([{ id: 'main', totalSalesUsd: newVol }])), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout actualizando métricas en Supabase.' - ); - - res.status(201).json({ log: newLog, totalSalesUsd: newVol }); - } catch (error: any) { - console.error("Supabase /api/ledger POST error:", error); - - if (!paymentLedger.find(l => l.id === newLog.id)) { - paymentLedger.unshift(newLog as ServerLedgerLog); - saveLedger(paymentLedger); - - const newVol = Number((mockVolumenSalesUsd + usdValue).toFixed(2)); - mockVolumenSalesUsd = newVol; - saveVolume(newVol); - } - - res.status(201).json({ log: newLog, totalSalesUsd: mockVolumenSalesUsd }); - } - }); - - app.delete("/api/ledger", async (req, res) => { - try { - await withTimeout( - Promise.resolve(supabase.from('ledger').delete().neq('id', 'clear_all')), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout limpiando ledger en Supabase.' - ); - await withTimeout( - Promise.resolve(supabase.from('store_metrics').upsert([{ id: 'main', totalSalesUsd: 0 }])), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout reiniciando métricas en Supabase.' - ); - mockVolumenSalesUsd = 0; - - res.json({ success: true, totalSalesUsd: 0.00 }); - } catch (error: any) { - console.error("Supabase /api/ledger DELETE error:", error); - paymentLedger = []; - saveLedger(paymentLedger); - mockVolumenSalesUsd = 0; - saveVolume(0); - res.json({ success: true, totalSalesUsd: 0.00 }); - } - }); - - // Gemini Scan Helper with exponential retries and fallback model - async function generateBotanicalContentWithRetry(client: any, imgPart: any, textPart: any, schema: any) { - const maxAttempts = 3; - let lastError: any = null; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - // Alternate models: Attempt 1 uses gemini-3.5-flash. If busy, try gemini-3.1-flash-lite immediately - const model = attempt === 1 ? "gemini-3.5-flash" : "gemini-3.1-flash-lite"; - console.log(`🤖 [Botanical Analysis] Attempt ${attempt}/${maxAttempts} using model: ${model}`); - try { - const response = await client.models.generateContent({ - model: model, - contents: { parts: [imgPart, textPart] }, - config: { - systemInstruction: "Eres un asesor agrónomo especializado experto en botánica. Devuelve exclusivamente el esquema JSON solicitado sin textos aclaratorios, markdown fuera del JSON ni introducciones.", - responseMimeType: "application/json", - responseSchema: schema, - }, - }); - if (response && response.text) { - return response; - } - throw new Error("Respuesta vacia"); - } catch (err: any) { - lastError = err; - console.log(`[Botanical Analysis] Model ${model} returned status: API_BUSY_OR_UNAVAILABLE`); - // If we still have attempts, sleep with exponential backoff - if (attempt < maxAttempts) { - const sleepMs = attempt * 1500; - console.log(`[Botanical Analysis] Sleeping ${sleepMs}ms before next model attempt...`); - await new Promise((resolve) => setTimeout(resolve, sleepMs)); - } - } - } - throw lastError || new Error("Se rebasaron todos los reintentos"); - } - - // Scan Plant Endpoint - app.post("/api/scan-plant", async (req, res) => { - const { base64Image, mimeType, isPresetSeed, presetIndex, targetElement } = req.body || {}; - - // Guardar imagen en el bucket publico de Supabase Storage si se provee. - let savedImagePath = ""; - if (base64Image && /^data:image\/[a-zA-Z0-9+.-]+;base64,/.test(base64Image)) { - try { - savedImagePath = await withTimeout( - uploadScanImageToSupabase(base64Image, mimeType), - 3500, - "La subida a Supabase Storage tardó demasiado." - ); - console.log(`📸 Imagen de escaneo guardada exitosamente en Supabase Storage (${PLANT_IMAGES_BUCKET}): ${savedImagePath}`); - } catch (err) { - console.error(`Error al guardar la imagen en Supabase Storage (${PLANT_IMAGES_BUCKET}):`, err); - } - } - - // Local quick sandbox preview presets - if (isPresetSeed) { - const idx = Number(presetIndex) >= 0 && Number(presetIndex) < PRESETS_BOTANICAL.length ? Number(presetIndex) : 0; - const item = PRESETS_BOTANICAL[idx]; - return res.json({ - success: true, - data: item, - method: "Pregenerado", - }); - } - - // Direct element override or selective mock pathway - if (targetElement && targetElement !== "Auto-detectar") { - const normalizedTarget = targetElement.toLowerCase() - .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // remove accents (e.g. raíz -> raiz) - .replace("clorofilia", "clorofila"); - - const matchedPreset = PRESETS_BOTANICAL.find(p => { - const pElement = (p.detectedElement || "").toLowerCase() - .normalize("NFD").replace(/[\u0300-\u036f]/g, "") - .replace("clorofilia", "clorofila"); - return pElement === normalizedTarget; - }); - - if (matchedPreset) { - console.log(`🎯 [Filtro Óptico] Retornando preset pre-integrado exacto para la estructura: ${targetElement}`); - return res.json({ - success: true, - data: { - ...matchedPreset, - image: savedImagePath || base64Image || matchedPreset.image, - detectedElement: targetElement // Preserve the exact title requested by the user - }, - method: "Análisis Óptico Directo", - }); - } - } - - const client = getGeminiClient(); - if (!client) { - console.log("Fallback modo simulación por falta de API Key."); - let selectedPreset = PRESETS_BOTANICAL[Math.floor(Math.random() * PRESETS_BOTANICAL.length)]; - - // If a targetElement was requested, try to find a match - if (targetElement && targetElement !== "Auto-detectar") { - const normalizedTarget = targetElement.toLowerCase() - .normalize("NFD").replace(/[\u0300-\u036f]/g, "") - .replace("clorofilia", "clorofila"); - - const found = PRESETS_BOTANICAL.find(p => { - const pElement = (p.detectedElement || "").toLowerCase() - .normalize("NFD").replace(/[\u0300-\u036f]/g, "") - .replace("clorofilia", "clorofila"); - return pElement === normalizedTarget; - }); - if (found) { - selectedPreset = found; - } - } - - const item = { ...selectedPreset }; - item.image = savedImagePath || getFallbackImageByPlantName(item.name); - return res.json({ - success: true, - data: item, - method: "Respaldo Local", - warning: "GEMINI_API_KEY no configurada o no válida. Cargada ficha botánica clasificada para " + (item.detectedElement || "Flora") - }); - } - - if (!base64Image) { - return res.status(400).json({ error: "Falta el archivo de imagen base64." }); - } - - try { - const cleanBase64 = base64Image.replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, ""); - const imgPart = { - inlineData: { - mimeType: mimeType || "image/jpeg", - data: cleanBase64, - }, - }; - - const textPart = { - text: "Analiza y examina detalladamente esta imagen vegetal. Identifica rigurosamente tanto la planta como su estructura o elemento visible principal. Devuelve estrictamente un objeto JSON en español que cumpla con el esquema requerido, asegurando que 'detectedElement' corresponda exactamente a uno de estos once términos según corresponda al aspecto visible en la imagen." + - (targetElement && targetElement !== "Auto-detectar" ? ` Nota especial: Enfoca prioritariamente la identificación en la estructura vegetal clasificada como: "${targetElement}".` : ""), - }; - - const responseSchema = { - type: Type.OBJECT, - properties: { - name: { type: Type.STRING, description: "Nombre común de la planta en español" }, - scientificName: { type: Type.STRING, description: "Nombre científico en latín" }, - origin: { type: Type.STRING, description: "Origen geográfico de la planta" }, - uses: { type: Type.STRING, description: "Para qué sirve y sus principales utilidades (comestible, medicinal, etc.) en español" }, - description: { type: Type.STRING, description: "Breve descripción botánica clara y cautivadora en español" }, - difficulty: { type: Type.STRING, description: "Dificultad de cultivo recomendado: 'Fácil', 'Moderado' o 'Difícil'" }, - suggestedPriceSol: { type: Type.NUMBER, description: "Precio sugerido de venta en SOL por ración (entre 0.01 y 0.1)" }, - suggestedPriceUsdc: { type: Type.NUMBER, description: "Precio sugerido en USDC (entre 0.5 y 3.0)" }, - suggestedPriceUsdt: { type: Type.NUMBER, description: "Precio sugerido en USDT (entre 0.5 y 3.0)" }, - category: { type: Type.STRING, description: "Categoría de cultivo: 'Hortalizas', 'Medicinales', 'Frutas', 'Hierbas' o 'Otro'" }, - watering: { type: Type.STRING, description: "Consejos de riego específicos para esta planta en español" }, - sunlight: { type: Type.STRING, description: "Requerimientos de luz solar y clima y exposición ideales en español" }, - idealSowingSeason: { type: Type.STRING, description: "Temporada o época ideal del año recomendada para sembrar en español" }, - harvestTimeDays: { type: Type.STRING, description: "Tiempo estimado (días, semanas, o meses) hasta ver la primera cosecha útil en español" }, - soilType: { type: Type.STRING, description: "Tipo de suelo, sustrato o tierra ideales para el crecimiento en español" }, - phRecommended: { type: Type.STRING, description: "Nivel de pH del suelo recomendado u óptimo" }, - companionPlants: { type: Type.STRING, description: "Plantas compañeras ideales que benefician su crecimiento en español" }, - pestPrevention: { type: Type.STRING, description: "Métodos ecológicos o remedios caseros para prevenir sus plagas habituales en español" }, - detectedElement: { type: Type.STRING, description: "El elemento o estructura vegetal detectado principalmente en la foto. Debe ser estrictamente uno de los siguientes: 'Plantas', 'Frutas', 'Frutos', 'Hojas', 'Clorofila', 'Raíz', 'Tallo', 'Flor', 'Semilla', 'Savia', 'Estomas'." }, - }, - required: [ - "name", - "scientificName", - "origin", - "uses", - "description", - "difficulty", - "suggestedPriceSol", - "suggestedPriceUsdc", - "suggestedPriceUsdt", - "category", - "watering", - "sunlight", - "idealSowingSeason", - "harvestTimeDays", - "soilType", - "phRecommended", - "companionPlants", - "pestPrevention", - "detectedElement", - ], - }; - - const response = await withTimeout( - generateBotanicalContentWithRetry(client, imgPart, textPart, responseSchema), - 8000, - "El análisis de Gemini tardó demasiado." - ); - - if (response && response.text) { - const parsedData = JSON.parse(response.text.trim()); - // Preserve user scanned local image path when available, otherwise fall back to Unsplash - parsedData.image = savedImagePath || getFallbackImageByPlantName(parsedData.name); - res.json({ - success: true, - data: parsedData, - method: "Gemini AI", - }); - } else { - throw new Error("No text response from Gemini"); - } - } catch (error: any) { - console.log("ℹ️ [Gemini Status] Nota: Las peticiones a la API de Gemini están muy congestionadas en este momento. Se ha activado la ficha botánica pre-integrada del invernadero local de forma automática."); - const randomIndex = Math.floor(Math.random() * PRESETS_BOTANICAL.length); - const item = { ...PRESETS_BOTANICAL[randomIndex] }; - // Always assign savedImagePath if available, then Unsplash fallback, never the huge base64 - item.image = savedImagePath || getFallbackImageByPlantName(item.name); - res.json({ - success: true, - data: item, - method: "Simulado", - error: error.message || "Servicio temporalmente congestionado", - }); - } - }); - - // Vite middleware setup - if (process.env.NODE_ENV !== "production") { - const vite = await createViteServer({ - server: { middlewareMode: true }, - appType: "spa", - }); - app.use(vite.middlewares); - } else { - const distPath = path.join(process.cwd(), "dist"); - app.use(express.static(distPath)); - app.get("*", (req, res) => { - res.sendFile(path.join(distPath, "index.html")); - }); - } - - if (!process.env.VERCEL) { - app.listen(REAL_PORT, "0.0.0.0", () => { - console.log(`🚀 Servidor full-stack corriendo en http://localhost:${REAL_PORT}`); - }); - } -} - -startServer(); diff --git a/api/index.ts b/api/index.ts index 6896760..0c14791 100644 --- a/api/index.ts +++ b/api/index.ts @@ -1,3 +1,3 @@ -import { app } from "../server.js"; +import { app } from "../server"; export default app; diff --git a/package-lock.json b/package-lock.json index 34e1cdb..0a9e9e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@google/genai": "^2.4.0", + "@solana/wallet-adapter-backpack": "^0.1.14", "@supabase/supabase-js": "^2.108.1", "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", @@ -249,6 +250,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -779,6 +790,35 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -1167,6 +1207,164 @@ "win32" ] }, + "node_modules/@solana/buffer-layout": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@solana/buffer-layout/-/buffer-layout-4.0.1.tgz", + "integrity": "sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==", + "license": "MIT", + "peer": true, + "dependencies": { + "buffer": "~6.0.3" + }, + "engines": { + "node": ">=5.10" + } + }, + "node_modules/@solana/codecs-core": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-2.3.0.tgz", + "integrity": "sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@solana/errors": "2.3.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.3.3" + } + }, + "node_modules/@solana/codecs-numbers": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-2.3.0.tgz", + "integrity": "sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@solana/codecs-core": "2.3.0", + "@solana/errors": "2.3.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.3.3" + } + }, + "node_modules/@solana/errors": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-2.3.0.tgz", + "integrity": "sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "chalk": "^5.4.1", + "commander": "^14.0.0" + }, + "bin": { + "errors": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.3.3" + } + }, + "node_modules/@solana/wallet-adapter-backpack": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/@solana/wallet-adapter-backpack/-/wallet-adapter-backpack-0.1.14.tgz", + "integrity": "sha512-DfNLd5S1P7rmrgqMp+jRd21ryuXUxia1mu4qmZ+cau1NGFO2v5ep14LhzYXmqPde6kgbzPLPkLdRnkffLdI4TA==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "Apache-2.0", + "dependencies": { + "@solana/wallet-adapter-base": "^0.9.23" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@solana/web3.js": "^1.77.3" + } + }, + "node_modules/@solana/wallet-adapter-base": { + "version": "0.9.27", + "resolved": "https://registry.npmjs.org/@solana/wallet-adapter-base/-/wallet-adapter-base-0.9.27.tgz", + "integrity": "sha512-kXjeNfNFVs/NE9GPmysBRKQ/nf+foSaq3kfVSeMcO/iVgigyRmB551OjU3WyAolLG/1jeEfKLqF9fKwMCRkUqg==", + "license": "Apache-2.0", + "dependencies": { + "@solana/wallet-standard-features": "^1.3.0", + "@wallet-standard/base": "^1.1.0", + "@wallet-standard/features": "^1.1.0", + "eventemitter3": "^5.0.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@solana/web3.js": "^1.98.0" + } + }, + "node_modules/@solana/wallet-standard-features": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@solana/wallet-standard-features/-/wallet-standard-features-1.3.0.tgz", + "integrity": "sha512-ZhpZtD+4VArf6RPitsVExvgkF+nGghd1rzPjd97GmBximpnt1rsUxMOEyoIEuH3XBxPyNB6Us7ha7RHWQR+abg==", + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/base": "^1.1.0", + "@wallet-standard/features": "^1.1.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@solana/web3.js": { + "version": "1.98.4", + "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.98.4.tgz", + "integrity": "sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.25.0", + "@noble/curves": "^1.4.2", + "@noble/hashes": "^1.4.0", + "@solana/buffer-layout": "^4.0.1", + "@solana/codecs-numbers": "^2.1.0", + "agentkeepalive": "^4.5.0", + "bn.js": "^5.2.1", + "borsh": "^0.7.0", + "bs58": "^4.0.1", + "buffer": "6.0.3", + "fast-stable-stringify": "^1.0.0", + "jayson": "^4.1.1", + "node-fetch": "^2.7.0", + "rpc-websockets": "^9.0.2", + "superstruct": "^2.0.2" + } + }, + "node_modules/@solana/web3.js/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "peer": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/@supabase/auth-js": { "version": "2.108.1", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.1.tgz", @@ -1251,6 +1449,16 @@ "node": ">=20.0.0" } }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", @@ -1564,7 +1772,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1678,6 +1885,23 @@ "@types/node": "*" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/ws": { + "version": "7.4.7", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", + "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -1698,6 +1922,27 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@wallet-standard/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@wallet-standard/base/-/base-1.1.1.tgz", + "integrity": "sha512-gggIHTtxicF9XFMQ12DkfS6NAG92Ak795JeSA7f2whAQ6Y3AkMWWuCMxSZXG2NIPN42kEaZSNVjqMsJRaJRxMQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22" + } + }, + "node_modules/@wallet-standard/features": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@wallet-standard/features/-/features-1.1.1.tgz", + "integrity": "sha512-aCWYmVeSCGViyEU5k7GMoW8zxE4Gs+C1s1Pp2XLesvSNlnZ4PMES9HUnTB3hl0b3RVj7C61yze3IWyrncqg4MA==", + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/base": "^1.1.1" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1720,6 +1965,19 @@ "node": ">= 14" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -1763,6 +2021,16 @@ "postcss": "^8.1.0" } }, + "node_modules/base-x": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", + "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==", + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -1804,6 +2072,13 @@ "node": "*" } }, + "node_modules/bn.js": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", + "license": "MIT", + "peer": true + }, "node_modules/body-parser": { "version": "1.20.5", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", @@ -1843,6 +2118,18 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/borsh": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/borsh/-/borsh-0.7.0.tgz", + "integrity": "sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "bn.js": "^5.2.0", + "bs58": "^4.0.0", + "text-encoding-utf-8": "^1.0.2" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -1876,12 +2163,62 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs58": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", + "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", + "license": "MIT", + "peer": true, + "dependencies": { + "base-x": "^3.0.2" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/bufferutil": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1940,6 +2277,29 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -2008,6 +2368,19 @@ } } }, + "node_modules/delay": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz", + "integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2135,6 +2508,23 @@ "node": ">= 0.4" } }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "license": "MIT", + "peer": true + }, + "node_modules/es6-promisify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "integrity": "sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "es6-promise": "^4.0.3" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -2200,6 +2590,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", @@ -2267,6 +2663,22 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/eyes": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", + "integrity": "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==", + "peer": true, + "engines": { + "node": "> 0.1.90" + } + }, + "node_modules/fast-stable-stringify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz", + "integrity": "sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==", + "license": "MIT", + "peer": true + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2610,6 +3022,16 @@ "node": ">= 14" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iceberg-js": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", @@ -2631,6 +3053,27 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2646,6 +3089,94 @@ "node": ">= 0.10" } }, + "node_modules/isomorphic-ws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", + "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/jayson": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/jayson/-/jayson-4.3.0.tgz", + "integrity": "sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/connect": "^3.4.33", + "@types/node": "^12.12.54", + "@types/ws": "^7.4.4", + "commander": "^2.20.3", + "delay": "^5.0.0", + "es6-promisify": "^5.0.0", + "eyes": "^0.1.8", + "isomorphic-ws": "^4.0.1", + "json-stringify-safe": "^5.0.1", + "stream-json": "^1.9.1", + "uuid": "^8.3.2", + "ws": "^7.5.10" + }, + "bin": { + "jayson": "bin/jayson.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jayson/node_modules/@types/node": { + "version": "12.20.55", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", + "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", + "license": "MIT", + "peer": true + }, + "node_modules/jayson/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT", + "peer": true + }, + "node_modules/jayson/node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/jayson/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2682,6 +3213,13 @@ "bignumber.js": "^9.0.0" } }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC", + "peer": true + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3162,6 +3700,19 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-releases": { "version": "2.0.47", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", @@ -3434,6 +3985,54 @@ "fsevents": "~2.3.2" } }, + "node_modules/rpc-websockets": { + "version": "9.3.9", + "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-9.3.9.tgz", + "integrity": "sha512-2iQDaTB4g5fDB2ihrTFSJSibCEuxaRi1q7qTW7ZO9/M5/TC+ToHA4D9/ffNLEbAoHNNrcdeP05oATNk44SKZXA==", + "license": "LGPL-3.0-only", + "peer": true, + "dependencies": { + "@swc/helpers": "^0.5.11", + "@types/uuid": "^10.0.0", + "@types/ws": "^8.2.2", + "buffer": "^6.0.3", + "eventemitter3": "^5.0.1", + "uuid": "^14.0.0", + "ws": "^8.5.0" + }, + "funding": { + "type": "paypal", + "url": "https://paypal.me/kozjak" + }, + "optionalDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^6.0.0" + } + }, + "node_modules/rpc-websockets/node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/rpc-websockets/node_modules/uuid": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "peer": true, + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -3625,6 +4224,33 @@ "node": ">= 0.8" } }, + "node_modules/stream-chain": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", + "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/stream-json": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", + "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "stream-chain": "^2.2.5" + } + }, + "node_modules/superstruct": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz", + "integrity": "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tailwindcss": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", @@ -3644,6 +4270,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/text-encoding-utf-8": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz", + "integrity": "sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==", + "peer": true + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3669,6 +4301,13 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "peer": true + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4169,7 +4808,6 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -4224,6 +4862,21 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/utf-8-validate": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.6.tgz", + "integrity": "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -4233,6 +4886,17 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "peer": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -4325,6 +4989,24 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "peer": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", diff --git a/package.json b/package.json index 2847dcc..4ae316e 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@google/genai": "^2.4.0", + "@solana/wallet-adapter-backpack": "^0.1.14", "@supabase/supabase-js": "^2.108.1", "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", diff --git a/server.ts b/server.ts index 910c29d..7347d17 100644 --- a/server.ts +++ b/server.ts @@ -13,63 +13,6 @@ const rawSupabaseUrl = "https://klaompnbmjufvhjkeeno.supabase.co"; const supabaseUrl = rawSupabaseUrl.replace(/\/rest\/v1\/?$/, ''); const supabaseAnonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtsYW9tcG5ibWp1ZnZoamtlZW5vIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODE1NjY5ODEsImV4cCI6MjA5NzE0Mjk4MX0.udKgeFZLsVzXvSU0oqR0F3_J7EDCA1g7MxF00l8LEEc"; const supabase = createClient(supabaseUrl, supabaseAnonKey); -const SUPABASE_QUERY_TIMEOUT_MS = 3000; -const PLANT_IMAGES_BUCKET = process.env.SUPABASE_STORAGE_BUCKET || "imagenes"; - -const normalizeImageExtension = (mimeType?: string) => { - const rawExt = mimeType?.split("/")?.[1]?.toLowerCase() || "jpg"; - if (rawExt === "jpeg") return "jpg"; - if (rawExt === "svg+xml") return "svg"; - return rawExt.replace(/[^a-z0-9]/g, "") || "jpg"; -}; - -const uploadScanImageToSupabase = async ( - base64Image: string, - mimeType?: string -): Promise => { - const parts = base64Image.split(";base64,"); - const cleanBase = parts.length > 1 ? parts[1] : base64Image; - const buffer = Buffer.from(cleanBase, "base64"); - const contentType = mimeType || base64Image.match(/^data:([^;]+);base64,/i)?.[1] || "image/jpeg"; - const ext = normalizeImageExtension(contentType); - const objectPath = `scans/scan-${Date.now()}-${Math.random().toString(36).slice(2, 10)}.${ext}`; - - const { error } = await supabase.storage - .from(PLANT_IMAGES_BUCKET) - .upload(objectPath, buffer, { - contentType, - upsert: false, - }); - - if (error) { - throw error; - } - - const { data } = supabase.storage - .from(PLANT_IMAGES_BUCKET) - .getPublicUrl(objectPath); - - return data.publicUrl; -}; - -const withTimeout = async ( - promise: Promise, - timeoutMs: number, - errorMessage: string -): Promise => { - let timeout: NodeJS.Timeout | undefined; - const timeoutPromise = new Promise((_, reject) => { - timeout = setTimeout(() => reject(new Error(errorMessage)), timeoutMs); - }); - - try { - return await Promise.race([promise, timeoutPromise]); - } finally { - if (timeout) { - clearTimeout(timeout); - } - } -}; // Initialize Gemini Client Lazily/Safely let aiClient: GoogleGenAI | null = null; @@ -558,9 +501,10 @@ function saveVolume(volume: number) { } } -let activeCrops: ServerCrop[] = loadCrops(); -let paymentLedger: ServerLedgerLog[] = loadLedger(); -let mockVolumenSalesUsd = loadVolume(); +// Removing local variables +let activeCrops: ServerCrop[] = []; +let paymentLedger: ServerLedgerLog[] = []; +let mockVolumenSalesUsd = 0; export const app = express(); @@ -569,36 +513,17 @@ async function startServer() { app.use(express.json({ limit: "15mb" })); app.use(express.urlencoded({ extended: true, limit: "15mb" })); - app.use((err: any, _req: any, res: any, next: any) => { - if (!err) { - return next(); - } - - console.error("Error procesando el cuerpo de la petición:", err); - const status = err.type === "entity.too.large" ? 413 : 400; - return res.status(status).json({ - success: false, - error: status === 413 - ? "La imagen es demasiado grande para procesarla. Intenta con una foto más liviana." - : "No se pudo interpretar la petición de análisis.", - }); - }); - app.use("/src/Imagenes", express.static(path.join(process.cwd(), "src", "Imagenes"))); - app.use("/src/imagenes", express.static(path.join(process.cwd(), "src", "imagenes"))); - + // Remove express.static for imagenes since we use Supabase now + // API Endpoints app.get("/api/crops", async (req, res) => { try { - const { data, error } = await withTimeout( - Promise.resolve(supabase.from('crops').select('*')), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout consultando cultivos en Supabase.' - ); + const { data, error } = await supabase.from('crops').select('*'); if (error) throw error; res.json(data || []); } catch (error: any) { console.error("Supabase /api/crops GET error:", error.message || error); - res.json(activeCrops); + res.status(500).json({ error: "Fallo al conectar con la base de datos (Supabase)." }); } }); @@ -638,107 +563,67 @@ async function startServer() { try { // Check if exists - const { data: existing } = await withTimeout( - Promise.resolve(supabase.from('crops').select('id').eq('id', newCrop.id).maybeSingle()), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout verificando cultivo existente en Supabase.' - ); + const { data: existing } = await supabase.from('crops').select('id').eq('id', newCrop.id).single(); if (existing) { return res.json(newCrop); } - const { data, error } = await withTimeout( - Promise.resolve(supabase.from('crops').insert([newCrop]).select()), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout guardando cultivo en Supabase.' - ); + const { data, error } = await supabase.from('crops').insert([newCrop]).select(); if (error) throw error; res.status(201).json(data?.[0] || newCrop); } catch (error: any) { console.error("Supabase /api/crops POST error:", JSON.stringify(error, null, 2), error.message); - if (!activeCrops.find(c => c.id === newCrop.id)) { - activeCrops.push(newCrop); - saveCrops(activeCrops); - } - res.status(201).json(newCrop); + res.status(500).json({ error: "Error al guardar el cultivo en base de datos. Verifica RLS y tablas." }); } }); app.put("/api/crops/:id", async (req, res) => { const { id } = req.params; try { - const { data, error } = await withTimeout( - Promise.resolve(supabase.from('crops').update(req.body).eq('id', id).select()), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout actualizando cultivo en Supabase.' - ); + const { data, error } = await supabase.from('crops').update(req.body).eq('id', id).select(); if (error) throw error; if (data && data.length > 0) { res.json(data[0]); } else { - res.status(404).json({ error: "Cultivo no encontrado" }); + res.status(404).json({ error: "Cultivo no encontrado en DB" }); } } catch (error: any) { console.error("Supabase /api/crops PUT error:", error); - const idx = activeCrops.findIndex(c => c.id === id); - if (idx !== -1) { - activeCrops[idx] = { ...activeCrops[idx], ...req.body }; - saveCrops(activeCrops); - res.json(activeCrops[idx]); - } else { - res.status(404).json({ error: "Cultivo no encontrado localmente" }); - } + res.status(500).json({ error: "Error actualizando el cultivo en DB." }); } }); app.delete("/api/crops/:id", async (req, res) => { const { id } = req.params; try { - const { error } = await withTimeout( - Promise.resolve(supabase.from('crops').delete().eq('id', id)), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout eliminando cultivo en Supabase.' - ); + const { error } = await supabase.from('crops').delete().eq('id', id); if (error) throw error; res.json({ success: true }); } catch (error: any) { console.error("Supabase /api/crops DELETE error:", error); - activeCrops = activeCrops.filter(c => c.id !== id); - saveCrops(activeCrops); - res.json({ success: true }); + res.status(500).json({ error: "Error borrando el cultivo en DB." }); } }); // Payment Ledger Endpoints app.get("/api/ledger", async (req, res) => { try { - const { data: ledgerData, error: ledgerError } = await withTimeout( - Promise.resolve(supabase.from('ledger').select('*').order('timestamp', { ascending: false })), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout consultando ledger en Supabase.' - ); + const { data: ledgerData, error: ledgerError } = await supabase.from('ledger').select('*').order('timestamp', { ascending: false }); if (ledgerError) throw ledgerError; - const { data: volumeData } = await withTimeout( - Promise.resolve(supabase.from('store_metrics').select('totalSalesUsd').eq('id', 'main').maybeSingle()), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout consultando métricas en Supabase.' - ); + const { data: volumeData } = await supabase.from('store_metrics').select('totalSalesUsd').eq('id', 'main').single(); res.json({ ledger: ledgerData || [], - totalSalesUsd: volumeData?.totalSalesUsd || mockVolumenSalesUsd || 0, + totalSalesUsd: volumeData?.totalSalesUsd || 0, }); } catch (error: any) { console.error("Supabase /api/ledger GET error:", error); - res.json({ - ledger: paymentLedger, - totalSalesUsd: mockVolumenSalesUsd - }); + res.status(500).json({ error: "Error obteniendo el ledger." }); } }); @@ -762,71 +647,41 @@ async function startServer() { } try { - const { error } = await withTimeout( - Promise.resolve(supabase.from('ledger').insert([newLog as any])), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout guardando ledger en Supabase.' - ); + const { error } = await supabase.from('ledger').insert([newLog as any]); if (error) throw error; - const newVol = Number((mockVolumenSalesUsd + usdValue).toFixed(2)); - mockVolumenSalesUsd = newVol; - await withTimeout( - Promise.resolve(supabase.from('store_metrics').upsert([{ id: 'main', totalSalesUsd: newVol }])), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout actualizando métricas en Supabase.' - ); + const { data: volumeData } = await supabase.from('store_metrics').select('totalSalesUsd').eq('id', 'main').single(); + const currentVol = volumeData?.totalSalesUsd || 0; + const newVol = Number((currentVol + usdValue).toFixed(2)); + + await supabase.from('store_metrics').upsert([{ id: 'main', totalSalesUsd: newVol }]); res.status(201).json({ log: newLog, totalSalesUsd: newVol }); } catch (error: any) { console.error("Supabase /api/ledger POST error:", error); - - if (!paymentLedger.find(l => l.id === newLog.id)) { - paymentLedger.unshift(newLog as ServerLedgerLog); - saveLedger(paymentLedger); - - const newVol = Number((mockVolumenSalesUsd + usdValue).toFixed(2)); - mockVolumenSalesUsd = newVol; - saveVolume(newVol); - } - - res.status(201).json({ log: newLog, totalSalesUsd: mockVolumenSalesUsd }); + res.status(500).json({ error: "Error guardando el pago." }); } }); app.delete("/api/ledger", async (req, res) => { try { - await withTimeout( - Promise.resolve(supabase.from('ledger').delete().neq('id', 'clear_all')), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout limpiando ledger en Supabase.' - ); - await withTimeout( - Promise.resolve(supabase.from('store_metrics').upsert([{ id: 'main', totalSalesUsd: 0 }])), - SUPABASE_QUERY_TIMEOUT_MS, - 'Timeout reiniciando métricas en Supabase.' - ); - mockVolumenSalesUsd = 0; + await supabase.from('ledger').delete().neq('id', 'clear_all'); + await supabase.from('store_metrics').upsert([{ id: 'main', totalSalesUsd: 0 }]); res.json({ success: true, totalSalesUsd: 0.00 }); } catch (error: any) { console.error("Supabase /api/ledger DELETE error:", error); - paymentLedger = []; - saveLedger(paymentLedger); - mockVolumenSalesUsd = 0; - saveVolume(0); - res.json({ success: true, totalSalesUsd: 0.00 }); + res.status(500).json({ error: "Error borrando el ledger." }); } }); // Gemini Scan Helper with exponential retries and fallback model async function generateBotanicalContentWithRetry(client: any, imgPart: any, textPart: any, schema: any) { - const maxAttempts = 3; + const maxAttempts = 2; // Reduced to avoid vercel timeout let lastError: any = null; for (let attempt = 1; attempt <= maxAttempts; attempt++) { - // Alternate models: Attempt 1 uses gemini-3.5-flash. If busy, try gemini-3.1-flash-lite immediately - const model = attempt === 1 ? "gemini-3.5-flash" : "gemini-3.1-flash-lite"; + const model = attempt === 1 ? "gemini-2.5-flash" : "gemini-1.5-flash"; console.log(`🤖 [Botanical Analysis] Attempt ${attempt}/${maxAttempts} using model: ${model}`); try { const response = await client.models.generateContent({ @@ -844,34 +699,59 @@ async function startServer() { throw new Error("Respuesta vacia"); } catch (err: any) { lastError = err; - console.log(`[Botanical Analysis] Model ${model} returned status: API_BUSY_OR_UNAVAILABLE`); - // If we still have attempts, sleep with exponential backoff + console.log(`[Botanical Analysis] Model ${model} returned error:`, err?.message || err); + // If we still have attempts, sleep short to avoid vercel limits if (attempt < maxAttempts) { - const sleepMs = attempt * 1500; - console.log(`[Botanical Analysis] Sleeping ${sleepMs}ms before next model attempt...`); - await new Promise((resolve) => setTimeout(resolve, sleepMs)); + await new Promise((resolve) => setTimeout(resolve, 500)); } } } - throw lastError || new Error("Se rebasaron todos los reintentos"); + console.error("Gemini failed after retries:", lastError); + return null; } // Scan Plant Endpoint app.post("/api/scan-plant", async (req, res) => { - const { base64Image, mimeType, isPresetSeed, presetIndex, targetElement } = req.body || {}; + const { base64Image, mimeType, isPresetSeed, presetIndex, targetElement } = req.body; - // Guardar imagen en el bucket publico de Supabase Storage si se provee. + // Guardar imagen en Supabase Storage de inmediato si se provee let savedImagePath = ""; - if (base64Image && /^data:image\/[a-zA-Z0-9+.-]+;base64,/.test(base64Image)) { + if (base64Image && /^data:image\/\w+;base64,/.test(base64Image)) { try { - savedImagePath = await withTimeout( - uploadScanImageToSupabase(base64Image, mimeType), - 3500, - "La subida a Supabase Storage tardó demasiado." - ); - console.log(`📸 Imagen de escaneo guardada exitosamente en Supabase Storage (${PLANT_IMAGES_BUCKET}): ${savedImagePath}`); + const parts = base64Image.split(";base64,"); + const cleanBase = parts.length > 1 ? parts[1] : base64Image; + const buffer = Buffer.from(cleanBase, "base64"); + + let ext = "jpg"; + if (mimeType) { + const mParts = mimeType.split("/"); + if (mParts.length > 1) ext = mParts[1]; + } else { + const match = base64Image.match(/^data:image\/([a-zA-Z0-9+.-]+);base64,/i); + if (match) ext = match[1]; + } + + // Normalizar extensiones comunes + ext = ext.toLowerCase(); + if (ext === "jpeg") ext = "jpg"; + else if (ext === "svg+xml") ext = "svg"; + + const fileName = `scan-${Date.now()}.${ext}`; + + // Subir a Supabase Storage: bucket 'imagenes' + const { data, error } = await supabase.storage.from("imagenes").upload(fileName, buffer, { + contentType: mimeType || `image/${ext}` + }); + + if (error) { + console.error("Error al subir imagen a Supabase:", error); + } else { + const { data: publicUrlData } = supabase.storage.from("imagenes").getPublicUrl(fileName); + savedImagePath = publicUrlData.publicUrl; + console.log(`📸 Imagen de escaneo guardada exitosamente en Supabase: ${savedImagePath}`); + } } catch (err) { - console.error(`Error al guardar la imagen en Supabase Storage (${PLANT_IMAGES_BUCKET}):`, err); + console.error("Error procesando o subiendo la imagen:", err); } } @@ -950,7 +830,7 @@ async function startServer() { } try { - const cleanBase64 = base64Image.replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, ""); + const cleanBase64 = base64Image.replace(/^data:image\/\w+;base64,/, ""); const imgPart = { inlineData: { mimeType: mimeType || "image/jpeg", @@ -1009,11 +889,7 @@ async function startServer() { ], }; - const response = await withTimeout( - generateBotanicalContentWithRetry(client, imgPart, textPart, responseSchema), - 8000, - "El análisis de Gemini tardó demasiado." - ); + const response = await generateBotanicalContentWithRetry(client, imgPart, textPart, responseSchema); if (response && response.text) { const parsedData = JSON.parse(response.text.trim()); diff --git a/src/components/PlantScanner.tsx b/src/components/PlantScanner.tsx index c38acb2..2976a37 100644 --- a/src/components/PlantScanner.tsx +++ b/src/components/PlantScanner.tsx @@ -52,9 +52,6 @@ export const PlantScanner: React.FC = ({ onCropIdentified }) }), }); const resultData = await res.json(); - if (!res.ok) { - throw new Error(resultData.error || `Error HTTP ${res.status} analizando la imagen`); - } if (resultData.success && resultData.data) { const cropRes: Crop = { @@ -98,14 +95,14 @@ export const PlantScanner: React.FC = ({ onCropIdentified }) } }; - const resizeImageBase64 = (base64Str: string): Promise => { - return new Promise((resolve, reject) => { + const resizeImageBase64 = (base64Str: string, mimeType: string = "image/jpeg"): Promise => { + return new Promise((resolve) => { const img = new Image(); img.src = base64Str; img.onload = () => { const canvas = document.createElement("canvas"); - const maxWidth = 256; - const maxHeight = 256; + const maxWidth = 320; + const maxHeight = 320; let width = img.width; let height = img.height; @@ -126,13 +123,13 @@ export const PlantScanner: React.FC = ({ onCropIdentified }) const ctx = canvas.getContext("2d"); if (ctx) { ctx.drawImage(img, 0, 0, width, height); - resolve(canvas.toDataURL("image/jpeg", 0.68)); + resolve(canvas.toDataURL(mimeType, 0.75)); } else { - reject(new Error("No se pudo preparar la imagen para el análisis.")); + resolve(base64Str); } }; img.onerror = () => { - reject(new Error("Formato de imagen no soportado por el navegador.")); + resolve(base64Str); }; }); }; @@ -147,17 +144,13 @@ export const PlantScanner: React.FC = ({ onCropIdentified }) const reader = new FileReader(); reader.onload = async () => { const parentBase64 = reader.result as string; - try { - const compressedBase64 = await resizeImageBase64(parentBase64); - triggerScanApi({ - base64Image: compressedBase64, - mimeType: "image/jpeg", - isPresetSeed: false, - }); - } catch (err: any) { - console.error(err); - alert(err.message || "No se pudo preparar la imagen para el análisis."); - } + const targetMimeType = file.type || "image/jpeg"; + const compressedBase64 = await resizeImageBase64(parentBase64, targetMimeType); + triggerScanApi({ + base64Image: compressedBase64, + mimeType: targetMimeType, + isPresetSeed: false, + }); }; reader.readAsDataURL(file); }; diff --git a/src/components/WalletHub.tsx b/src/components/WalletHub.tsx index 935fa40..a1d3822 100644 --- a/src/components/WalletHub.tsx +++ b/src/components/WalletHub.tsx @@ -10,6 +10,9 @@ interface WalletHubProps { onAirdrop: (token: 'SOL' | 'USDC' | 'USDT', amount: number) => void; } +const BACKPACK_ICON = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAbvSURBVHgB7Z1dUtxGEMf/LZH3fU0V4PUJQg4QVj5BnBOAT2BzAsMJAicwPoHJCRDrAxifgLVxVV73ObDqdEtsjKn4C8+0NDv9e7AxprRC85uvnp4RYYW5qKpxCVTcYKsgfiDfGjMwIsZIvh7d/lkmzAiYy5fzhultyZhdlagf1vU5VhjCiiGFXq01zYSJdqWgx/hB5AHN5I/6iuilyFBjxVgZAdqCZ34ORoVIqAzSOhxsvq6PsSIkL4A281LwL2IW/F1UhLKgRz/X9QyJUyBhuuae31gWviLjiPF1wxeX29vPkTjJtgAftrd3GHSMnmHw4eZ0uodESVKAoRT+kpQlSE6Ats/XZv/ONK5vZHC49+B1fYjESG4MUDKfYmCFr0ic4fmHqtpCYiQlgA66QsztIzFi5j+RGMl0AXebfgn0aOTuvGG8owIarZsXOj3ronlRuEYnn84CJLo4Lgi/QL/H/LHmy/RwI6GA0RoS4acFHi8kGieFXS/QhmijFfQXmH3uPy5lSkoLbIkYlfyzhuM4juM4juM4juMMj6TzATQ4JH9tlRqFk8BM2aV9RWHB9K5kzK/KLui0KqliSQmgBa4BIS54cpMD0OeawFye3jk19JdKkWq62OAFkEIfrTXNUxBV1okf38Ot3MGjlFqHwQrQZvQ22Cfw7xjg6t8XkZaBGzpKIXdwcAJojZeCP5SC30HipJBEOigBZLn3qdzSPlKr8V9hyEmkgxCgj8zefuD9jen0AAOidwE0i6ZhfjXgRI+gDK016DUjqE3ubPhNLoWvaDLJouHToaSP9SbA0DJ7LekyiviNPgP0TC9dQM6FfxeZ7eyuT6cv0RPmAmjTx11uXx/MiegEDd425cfcwWV+H4O3+uiO+pTAVIA2uMN8av6QiWr5TQ++JVlTc/tEiF3jOMScZGC43kME0VSA95PJhWXhM+Gt1Phn98nStZa1r9mB2SDQPqefjhayfnDfFG2J5882z84eynVM5u3thlONhRhj0gLc5PRfwAw62JjW+wjE5Xa1L0VkshO4kXt/EPDev4ZJCyBRvlcwggjHG4EfYHc9OoIBBWy3mEUX4H1V7Ur7ZvILaT8qy7FRduleF9jXc4RggOUWs/gtANs0nYquvMXaMaTXlQHlE1ggayLvf5OKY0DUMYDWfmpsBjZa+9enOmiLy+VkcmqxaNW2ZgX9GnsLXNQWoGj4KYzQ2g8LyG5WUDR4hshEE6CN+AFmg5lFiRMYcI0uKRQGyIAwegWKJkBjYO8tzq12C7efQ7CK2I00MomIxOsCiCcwQhaW3sEQ6W7sPi/yIDqKAHp8m2nIF7COoc9ghQw4NU8SkYgiQCmLKXCCUSziPc84XYBh83/DSiWR3qUo2tT4ONdGYDTub73cSzD/PNt0rojdQHAByoXxw0E7XfoFhsjnRduD+DnWIkkXXACJl1cwRoMmf3cbRaOjLRzDXnKZVj9GBIILUJBtbVzyj9HAU19AgR6I9VzDtwCgMXpAo2Yxp0v/Ybi49ennJtIFEPMY/TCKHTvv+aTSUQzBgwrQ92YHbQVi3UN3GAVZhrf/jzECE1SAq/7n4yOJ074KPSBcJoii598vxgwrqAByg70HZJZbr0JJ0G5XZz5Z1e1rYccA5TAicqEk0O5ECl/3LvYys7mLTLHHCEzS7wz6Esv3+nyYTF58rwha63XAl8PG1aCnhesWq6EdOcKM3WvmXRHh+Gvv/tNVTJlJPC4a3RVEK72+sCSZ4+J/FBVhTUS43J7gJqFjrnl33A3sxtCa3nAWhX6bbAT4hJugCsNZ2TGA8224AJnjAmSOC5A5LkDmuACZ4wJkjguQOS5A5rgAmeMCZI4LkDkuQOa4AJnjAmSOC5A5LkDmuACZ4wJkjguQOWEFYJvz85xwBBWgKM1P68oKKsI/36ACdC9nsDlWPTsIJ5t1Hfw01OBjgI1p/YwLegIibw0CwESz9gUYZ2d/wHEcx3Ecx3Ecx3Ecx3HuS5QjfdrXxTHv3JzEkd2xKwHR9xPNuKGjzdf1MSIQXAA9XUsuuw8nKPpK3PWzs+AvrgwqgP1LojOjoEf3fRv6Zy+JgBSLOGfaOx1NE/6o+rCrgeT9fWp4SljmuACZ4wJkjguQOS5A5rgAmeMCZI4LkDkuQOa4AJnjAmSOC5A5LkDmuACZ4wJkjguQOS5A5rgAmeMCZI4LkDkuQOa4AJnj5wRmTlABqHQBohKhggUVYAEEP8fO+UiMgziDCvCwrnU3aw0nOATMQu8LVIIPAq+JdAerdwWBaQ/fjEBwAaQVmMnN7sEJCB3EqP3tlRGJy6qqmPkFMcZw7sucmfZiHQ6hRBNgSXdaCHbA7KeFfBvz9pxlxtl1gcN2XBWRfwHK959XFRG6AgAAAABJRU5ErkJggg=="; +const PHANTOM_ICON = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDgiIGhlaWdodD0iMTA4IiB2aWV3Qm94PSIwIDAgMTA4IDEwOCIgZmlsbD0ibm9uZSI+CjxyZWN0IHdpZHRoPSIxMDgiIGhlaWdodD0iMTA4IiByeD0iMjYiIGZpbGw9IiNBQjlGRjIiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik00Ni41MjY3IDY5LjkyMjlDNDIuMDA1NCA3Ni44NTA5IDM0LjQyOTIgODUuNjE4MiAyNC4zNDggODUuNjE4MkMxOS41ODI0IDg1LjYxODIgMTUgODMuNjU2MyAxNSA3NS4xMzQyQzE1IDUzLjQzMDUgNDQuNjMyNiAxOS44MzI3IDcyLjEyNjggMTkuODMyN0M4Ny43NjggMTkuODMyNyA5NCAzMC42ODQ2IDk0IDQzLjAwNzlDOTQgNTguODI1OCA4My43MzU1IDc2LjkxMjIgNzMuNTMyMSA3Ni45MTIyQzcwLjI5MzkgNzYuOTEyMiA2OC43MDUzIDc1LjEzNDIgNjguNzA1MyA3Mi4zMTRDNjguNzA1MyA3MS41NzgzIDY4LjgyNzUgNzAuNzgxMiA2OS4wNzE5IDY5LjkyMjlDNjUuNTg5MyA3NS44Njk5IDU4Ljg2ODUgODEuMzg3OCA1Mi41NzU0IDgxLjM4NzhDNDcuOTkzIDgxLjM4NzggNDUuNjcxMyA3OC41MDYzIDQ1LjY3MTMgNzQuNDU5OEM0NS42NzEzIDcyLjk4ODQgNDUuOTc2OCA3MS40NTU2IDQ2LjUyNjcgNjkuOTIyOVpNODMuNjc2MSA0Mi41Nzk0QzgzLjY3NjEgNDYuMTcwNCA4MS41NTc1IDQ3Ljk2NTggNzkuMTg3NSAzNy4xOTMxQzgxLjU1NzUgMzcuMTkzMSA4My42NzYxIDM4Ljk4ODUgODMuNjc2MSA0Mi41Nzk0Wk03MC4yMTAzIDQyLjU3OTVDNzAuMjEwMyA0Ni4xNzA0IDY4LjA5MTYgNDcuOTY1OCA2NS43MjE2IDQ3Ljk2NThDNjMuMzE1NyA0Ny45NjU4IDYxLjIzMyA0Ni4xNzA0IDYxLjIzMyA0Mi41Nzk1QzYxLjIzMyAzOC45ODg1IDYzLjMxNTcgMzcuMTkzMSA2NS43MjE2IDM3LjE5MzFDNjguMDkxNiAzNy4xOTMxIDcwLjIxMDMgMzguOTg4NSA3MC4yMTAzIDQyLjU3OTVaIiBmaWxsPSIjRkZGREY4Ii8+Cjwvc3ZnPg=="; + export const WalletHub: React.FC = ({ walletState, onConnectWallet, @@ -56,18 +59,6 @@ export const WalletHub: React.FC = ({ exit={{ opacity: 0, y: -10 }} className="space-y-4" > -
- -
-

¿Eres nuevo en Solana?

-

- Las transacciones en Solana tardan menos de 1 segundo y cuestan apenas{" "} - ~0.000005 SOL ($0.00025 USD). - Conecta una cartera simulada para ver la magia de Solana Pay en acción. -

-
-
-

SELECCIONA TU CARTERA SIMULADA: @@ -79,7 +70,7 @@ export const WalletHub: React.FC = ({ className="flex flex-col items-center justify-center p-3 rounded-xl border border-purple-500/35 bg-purple-950/40 hover:bg-purple-900/40 active:scale-95 transition text-purple-200 gap-2 hover:border-purple-400" >

- 👻 + Phantom
Phantom @@ -89,8 +80,8 @@ export const WalletHub: React.FC = ({ onClick={() => onConnectWallet("Solflare")} className="flex flex-col items-center justify-center p-3 rounded-xl border border-amber-500/35 bg-amber-950/40 hover:bg-amber-900/40 active:scale-95 transition text-amber-200 gap-2 hover:border-amber-400" > -
- 🔥 +
+ Solflare
Solflare @@ -101,7 +92,7 @@ export const WalletHub: React.FC = ({ className="flex flex-col items-center justify-center p-3 rounded-xl border border-rose-500/35 bg-rose-950/40 hover:bg-rose-900/40 active:scale-95 transition text-rose-200 gap-2 hover:border-rose-400" >
- 🎒 + Backpack
Backpack @@ -120,8 +111,8 @@ export const WalletHub: React.FC = ({
- - {walletState.walletName === "Phantom" ? "👻" : walletState.walletName === "Solflare" ? "🔥" : "🎒"} + + {walletState.walletName === "Phantom" ? Phantom : walletState.walletName === "Solflare" ? Solflare : Backpack} {walletState.walletName} Conectado diff --git a/temp_phantom/adapter.ts b/temp_phantom/adapter.ts new file mode 100644 index 0000000..326c1cc --- /dev/null +++ b/temp_phantom/adapter.ts @@ -0,0 +1,307 @@ +import type { EventEmitter, SendTransactionOptions, WalletName } from '@solana/wallet-adapter-base'; +import { + BaseMessageSignerWalletAdapter, + isIosAndRedirectable, + isVersionedTransaction, + scopePollingDetectionStrategy, + WalletAccountError, + WalletConnectionError, + WalletDisconnectedError, + WalletDisconnectionError, + WalletError, + WalletNotConnectedError, + WalletNotReadyError, + WalletPublicKeyError, + WalletReadyState, + WalletSendTransactionError, + WalletSignMessageError, + WalletSignTransactionError, +} from '@solana/wallet-adapter-base'; +import type { + Connection, + SendOptions, + Transaction, + TransactionSignature, + TransactionVersion, + VersionedTransaction, +} from '@solana/web3.js'; +import { PublicKey } from '@solana/web3.js'; + +interface PhantomWalletEvents { + connect(...args: unknown[]): unknown; + disconnect(...args: unknown[]): unknown; + accountChanged(newPublicKey: PublicKey): unknown; +} + +interface PhantomWallet extends EventEmitter { + isPhantom?: boolean; + publicKey?: { toBytes(): Uint8Array }; + isConnected: boolean; + signTransaction(transaction: T): Promise; + signAllTransactions(transactions: T[]): Promise; + signAndSendTransaction( + transaction: T, + options?: SendOptions + ): Promise<{ signature: TransactionSignature }>; + signMessage(message: Uint8Array): Promise<{ signature: Uint8Array }>; + connect(): Promise; + disconnect(): Promise; +} + +interface PhantomWindow extends Window { + phantom?: { + solana?: PhantomWallet; + }; + solana?: PhantomWallet; + isPhantomInstalled?: boolean; +} + +declare const window: PhantomWindow; + +export interface PhantomWalletAdapterConfig {} + +export const PhantomWalletName = 'Phantom' as WalletName<'Phantom'>; + +export class PhantomWalletAdapter extends BaseMessageSignerWalletAdapter { + name = PhantomWalletName; + url = 'https://phantom.app'; + icon = + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDgiIGhlaWdodD0iMTA4IiB2aWV3Qm94PSIwIDAgMTA4IDEwOCIgZmlsbD0ibm9uZSI+CjxyZWN0IHdpZHRoPSIxMDgiIGhlaWdodD0iMTA4IiByeD0iMjYiIGZpbGw9IiNBQjlGRjIiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik00Ni41MjY3IDY5LjkyMjlDNDIuMDA1NCA3Ni44NTA5IDM0LjQyOTIgODUuNjE4MiAyNC4zNDggODUuNjE4MkMxOS41ODI0IDg1LjYxODIgMTUgODMuNjU2MyAxNSA3NS4xMzQyQzE1IDUzLjQzMDUgNDQuNjMyNiAxOS44MzI3IDcyLjEyNjggMTkuODMyN0M4Ny43NjggMTkuODMyNyA5NCAzMC42ODQ2IDk0IDQzLjAwNzlDOTQgNTguODI1OCA4My43MzU1IDc2LjkxMjIgNzMuNTMyMSA3Ni45MTIyQzcwLjI5MzkgNzYuOTEyMiA2OC43MDUzIDc1LjEzNDIgNjguNzA1MyA3Mi4zMTRDNjguNzA1MyA3MS41NzgzIDY4LjgyNzUgNzAuNzgxMiA2OS4wNzE5IDY5LjkyMjlDNjUuNTg5MyA3NS44Njk5IDU4Ljg2ODUgODEuMzg3OCA1Mi41NzU0IDgxLjM4NzhDNDcuOTkzIDgxLjM4NzggNDUuNjcxMyA3OC41MDYzIDQ1LjY3MTMgNzQuNDU5OEM0NS42NzEzIDcyLjk4ODQgNDUuOTc2OCA3MS40NTU2IDQ2LjUyNjcgNjkuOTIyOVpNODMuNjc2MSA0Mi41Nzk0QzgzLjY3NjEgNDYuMTcwNCA4MS41NTc1IDQ3Ljk2NTggNzkuMTg3NSA0Ny45NjU4Qzc2Ljc4MTYgNDcuOTY1OCA3NC42OTg5IDQ2LjE3MDQgNzQuNjk4OSA0Mi41Nzk0Qzc0LjY5ODkgMzguOTg4NSA3Ni43ODE2IDM3LjE5MzEgNzkuMTg3NSAzNy4xOTMxQzgxLjU1NzUgMzcuMTkzMSA4My42NzYxIDM4Ljk4ODUgODMuNjc2MSA0Mi41Nzk0Wk03MC4yMTAzIDQyLjU3OTVDNzAuMjEwMyA0Ni4xNzA0IDY4LjA5MTYgNDcuOTY1OCA2NS43MjE2IDQ3Ljk2NThDNjMuMzE1NyA0Ny45NjU4IDYxLjIzMyA0Ni4xNzA0IDYxLjIzMyA0Mi41Nzk1QzYxLjIzMyAzOC45ODg1IDYzLjMxNTcgMzcuMTkzMSA2NS43MjE2IDM3LjE5MzFDNjguMDkxNiAzNy4xOTMxIDcwLjIxMDMgMzguOTg4NSA3MC4yMTAzIDQyLjU3OTVaIiBmaWxsPSIjRkZGREY4Ii8+Cjwvc3ZnPg=='; + supportedTransactionVersions: ReadonlySet = new Set(['legacy', 0]); + + private _connecting: boolean; + private _wallet: PhantomWallet | null; + private _publicKey: PublicKey | null; + private _readyState: WalletReadyState = + typeof window === 'undefined' || typeof document === 'undefined' + ? WalletReadyState.Unsupported + : WalletReadyState.NotDetected; + + constructor(config: PhantomWalletAdapterConfig = {}) { + super(); + this._connecting = false; + this._wallet = null; + this._publicKey = null; + + if (this._readyState !== WalletReadyState.Unsupported) { + if (isIosAndRedirectable()) { + // when in iOS (not webview), set Phantom as loadable instead of checking for install + this._readyState = WalletReadyState.Loadable; + this.emit('readyStateChange', this._readyState); + } else { + scopePollingDetectionStrategy(() => { + if (window?.isPhantomInstalled && (window.phantom?.solana?.isPhantom || window.solana?.isPhantom)) { + this._readyState = WalletReadyState.Installed; + this.emit('readyStateChange', this._readyState); + return true; + } + return false; + }); + } + } + } + + get publicKey() { + return this._publicKey; + } + + get connecting() { + return this._connecting; + } + + get readyState() { + return this._readyState; + } + + async autoConnect(): Promise { + // Skip autoconnect in the Loadable state + // We can't redirect to a universal link without user input + if (this.readyState === WalletReadyState.Installed) { + await this.connect(); + } + } + + async connect(): Promise { + try { + if (this.connected || this.connecting) return; + + if (this.readyState === WalletReadyState.Loadable) { + // redirect to the Phantom /browse universal link + // this will open the current URL in the Phantom in-wallet browser + const url = encodeURIComponent(window.location.href); + const ref = encodeURIComponent(window.location.origin); + window.location.href = `https://phantom.app/ul/browse/${url}?ref=${ref}`; + return; + } + + if (this.readyState !== WalletReadyState.Installed) throw new WalletNotReadyError(); + + this._connecting = true; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const wallet = window.phantom?.solana || window.solana!; + + if (!wallet.isConnected) { + try { + await wallet.connect(); + } catch (error: any) { + throw new WalletConnectionError(error?.message, error); + } + } + + if (!wallet.publicKey) throw new WalletAccountError(); + + let publicKey: PublicKey; + try { + publicKey = new PublicKey(wallet.publicKey.toBytes()); + } catch (error: any) { + throw new WalletPublicKeyError(error?.message, error); + } + + wallet.on('disconnect', this._disconnected); + wallet.on('accountChanged', this._accountChanged); + + this._wallet = wallet; + this._publicKey = publicKey; + + this.emit('connect', publicKey); + } catch (error: any) { + this.emit('error', error); + throw error; + } finally { + this._connecting = false; + } + } + + async disconnect(): Promise { + const wallet = this._wallet; + if (wallet) { + wallet.off('disconnect', this._disconnected); + wallet.off('accountChanged', this._accountChanged); + + this._wallet = null; + this._publicKey = null; + + try { + await wallet.disconnect(); + } catch (error: any) { + this.emit('error', new WalletDisconnectionError(error?.message, error)); + } + } + + this.emit('disconnect'); + } + + async sendTransaction( + transaction: T, + connection: Connection, + options: SendTransactionOptions = {} + ): Promise { + try { + const wallet = this._wallet; + if (!wallet) throw new WalletNotConnectedError(); + + try { + const { signers, ...sendOptions } = options; + + if (isVersionedTransaction(transaction)) { + signers?.length && transaction.sign(signers); + } else { + transaction = (await this.prepareTransaction(transaction, connection, sendOptions)) as T; + signers?.length && (transaction as Transaction).partialSign(...signers); + } + + sendOptions.preflightCommitment = sendOptions.preflightCommitment || connection.commitment; + + const { signature } = await wallet.signAndSendTransaction(transaction, sendOptions); + return signature; + } catch (error: any) { + if (error instanceof WalletError) throw error; + throw new WalletSendTransactionError(error?.message, error); + } + } catch (error: any) { + this.emit('error', error); + throw error; + } + } + + async signTransaction(transaction: T): Promise { + try { + const wallet = this._wallet; + if (!wallet) throw new WalletNotConnectedError(); + + try { + return (await wallet.signTransaction(transaction)) || transaction; + } catch (error: any) { + throw new WalletSignTransactionError(error?.message, error); + } + } catch (error: any) { + this.emit('error', error); + throw error; + } + } + + async signAllTransactions(transactions: T[]): Promise { + try { + const wallet = this._wallet; + if (!wallet) throw new WalletNotConnectedError(); + + try { + return (await wallet.signAllTransactions(transactions)) || transactions; + } catch (error: any) { + throw new WalletSignTransactionError(error?.message, error); + } + } catch (error: any) { + this.emit('error', error); + throw error; + } + } + + async signMessage(message: Uint8Array): Promise { + try { + const wallet = this._wallet; + if (!wallet) throw new WalletNotConnectedError(); + + try { + const { signature } = await wallet.signMessage(message); + return signature; + } catch (error: any) { + throw new WalletSignMessageError(error?.message, error); + } + } catch (error: any) { + this.emit('error', error); + throw error; + } + } + + private _disconnected = () => { + const wallet = this._wallet; + if (wallet) { + wallet.off('disconnect', this._disconnected); + wallet.off('accountChanged', this._accountChanged); + + this._wallet = null; + this._publicKey = null; + + this.emit('error', new WalletDisconnectedError()); + this.emit('disconnect'); + } + }; + + private _accountChanged = (newPublicKey: PublicKey) => { + const publicKey = this._publicKey; + if (!publicKey) return; + + try { + newPublicKey = new PublicKey(newPublicKey.toBytes()); + } catch (error: any) { + this.emit('error', new WalletPublicKeyError(error?.message, error)); + return; + } + + if (publicKey.equals(newPublicKey)) return; + + this._publicKey = newPublicKey; + this.emit('connect', newPublicKey); + }; +} diff --git a/temp_phantom/index.ts b/temp_phantom/index.ts new file mode 100644 index 0000000..ddec7b5 --- /dev/null +++ b/temp_phantom/index.ts @@ -0,0 +1 @@ +export * from './adapter.js'; diff --git a/temp_phantom_pkg/CHANGELOG.md b/temp_phantom_pkg/CHANGELOG.md new file mode 100644 index 0000000..3291447 --- /dev/null +++ b/temp_phantom_pkg/CHANGELOG.md @@ -0,0 +1,90 @@ +# @solana/wallet-adapter-phantom + +## 0.9.29 + +### Patch Changes + +- f30323d: Improve Phantom detection logic to avoid false positives + +## 0.9.28 + +### Patch Changes + +- 75bf350: Update dependencies +- Updated dependencies [75bf350] + - @solana/wallet-adapter-base@0.9.27 + +## 0.9.27 + +### Patch Changes + +- db923f1: Use Node 20+ rather than 22 +- Updated dependencies [db923f1] + - @solana/wallet-adapter-base@0.9.26 + +## 0.9.26 + +### Patch Changes + +- 27e408d: Update dependencies +- Updated dependencies [27e408d] + - @solana/wallet-adapter-base@0.9.25 + +## 0.9.25 + +### Patch Changes + +- c96cae47: The base version of Node has been raised to v20 +- Updated dependencies [c96cae47] + - @solana/wallet-adapter-base@0.9.24 + +## 0.9.24 + +### Patch Changes + +- Updated dependencies [a3d35a1] + - @solana/wallet-adapter-base@0.9.23 + +## 0.9.23 + +### Patch Changes + +- 4de663e: Phantom logo updated with rebrand (https://twitter.com/phantom/status/1674513272115175424) + +## 0.9.22 + +### Patch Changes + +- 8a8fdc72: Update dependencies +- Updated dependencies [8a8fdc72] + - @solana/wallet-adapter-base@0.9.22 + +## 0.9.21 + +### Patch Changes + +- f99c2154: Fix for Phantom adapter's `connected` state +- Updated dependencies [f99c2154] + - @solana/wallet-adapter-base@0.9.21 + +## 0.9.20 + +### Patch Changes + +- b4558126: Add support for redirecting to Solflare browser on iOS + +## 0.9.19 + +### Patch Changes + +- 912cc0e: Allow wallets to customize autoConnect handling, adding support for Phantom deep links on iOS +- Updated dependencies [912cc0e] + - @solana/wallet-adapter-base@0.9.20 + +## 0.9.18 + +### Patch Changes + +- fed93f5: Add support for VersionedTransaction to Phantom adapter +- Updated dependencies [353f2a5] + - @solana/wallet-adapter-base@0.9.19 diff --git a/temp_phantom_pkg/LICENSE b/temp_phantom_pkg/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/temp_phantom_pkg/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/temp_phantom_pkg/README.md b/temp_phantom_pkg/README.md new file mode 100644 index 0000000..decd3f9 --- /dev/null +++ b/temp_phantom_pkg/README.md @@ -0,0 +1,5 @@ +# `@solana/wallet-adapter-phantom` + + + +Coming soon. \ No newline at end of file diff --git a/temp_phantom_pkg/package.json b/temp_phantom_pkg/package.json new file mode 100644 index 0000000..5fec88f --- /dev/null +++ b/temp_phantom_pkg/package.json @@ -0,0 +1,44 @@ +{ + "name": "@solana/wallet-adapter-phantom", + "version": "0.9.29", + "author": "Solana Maintainers ", + "repository": "https://github.com/anza-xyz/wallet-adapter", + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "files": [ + "lib", + "src", + "LICENSE" + ], + "engines": { + "node": ">=20" + }, + "type": "module", + "sideEffects": false, + "main": "./lib/cjs/index.js", + "module": "./lib/esm/index.js", + "types": "./lib/types/index.d.ts", + "exports": { + "require": "./lib/cjs/index.js", + "import": "./lib/esm/index.js", + "types": "./lib/types/index.d.ts" + }, + "scripts": { + "build": "tsc --build --verbose && pnpm run package", + "clean": "shx mkdir -p lib && shx rm -rf lib", + "lint": "prettier --check 'src/{*,**/*}.{ts,tsx,js,jsx,json}' && eslint", + "package": "shx mkdir -p lib/cjs && shx echo '{ \"type\": \"commonjs\" }' > lib/cjs/package.json" + }, + "peerDependencies": { + "@solana/web3.js": "^1.98.0" + }, + "dependencies": { + "@solana/wallet-adapter-base": "workspace:^" + }, + "devDependencies": { + "@solana/web3.js": "^1.98.2", + "shx": "^0.4.0" + } +} diff --git a/temp_phantom_pkg/src/adapter.ts b/temp_phantom_pkg/src/adapter.ts new file mode 100644 index 0000000..326c1cc --- /dev/null +++ b/temp_phantom_pkg/src/adapter.ts @@ -0,0 +1,307 @@ +import type { EventEmitter, SendTransactionOptions, WalletName } from '@solana/wallet-adapter-base'; +import { + BaseMessageSignerWalletAdapter, + isIosAndRedirectable, + isVersionedTransaction, + scopePollingDetectionStrategy, + WalletAccountError, + WalletConnectionError, + WalletDisconnectedError, + WalletDisconnectionError, + WalletError, + WalletNotConnectedError, + WalletNotReadyError, + WalletPublicKeyError, + WalletReadyState, + WalletSendTransactionError, + WalletSignMessageError, + WalletSignTransactionError, +} from '@solana/wallet-adapter-base'; +import type { + Connection, + SendOptions, + Transaction, + TransactionSignature, + TransactionVersion, + VersionedTransaction, +} from '@solana/web3.js'; +import { PublicKey } from '@solana/web3.js'; + +interface PhantomWalletEvents { + connect(...args: unknown[]): unknown; + disconnect(...args: unknown[]): unknown; + accountChanged(newPublicKey: PublicKey): unknown; +} + +interface PhantomWallet extends EventEmitter { + isPhantom?: boolean; + publicKey?: { toBytes(): Uint8Array }; + isConnected: boolean; + signTransaction(transaction: T): Promise; + signAllTransactions(transactions: T[]): Promise; + signAndSendTransaction( + transaction: T, + options?: SendOptions + ): Promise<{ signature: TransactionSignature }>; + signMessage(message: Uint8Array): Promise<{ signature: Uint8Array }>; + connect(): Promise; + disconnect(): Promise; +} + +interface PhantomWindow extends Window { + phantom?: { + solana?: PhantomWallet; + }; + solana?: PhantomWallet; + isPhantomInstalled?: boolean; +} + +declare const window: PhantomWindow; + +export interface PhantomWalletAdapterConfig {} + +export const PhantomWalletName = 'Phantom' as WalletName<'Phantom'>; + +export class PhantomWalletAdapter extends BaseMessageSignerWalletAdapter { + name = PhantomWalletName; + url = 'https://phantom.app'; + icon = + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDgiIGhlaWdodD0iMTA4IiB2aWV3Qm94PSIwIDAgMTA4IDEwOCIgZmlsbD0ibm9uZSI+CjxyZWN0IHdpZHRoPSIxMDgiIGhlaWdodD0iMTA4IiByeD0iMjYiIGZpbGw9IiNBQjlGRjIiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik00Ni41MjY3IDY5LjkyMjlDNDIuMDA1NCA3Ni44NTA5IDM0LjQyOTIgODUuNjE4MiAyNC4zNDggODUuNjE4MkMxOS41ODI0IDg1LjYxODIgMTUgODMuNjU2MyAxNSA3NS4xMzQyQzE1IDUzLjQzMDUgNDQuNjMyNiAxOS44MzI3IDcyLjEyNjggMTkuODMyN0M4Ny43NjggMTkuODMyNyA5NCAzMC42ODQ2IDk0IDQzLjAwNzlDOTQgNTguODI1OCA4My43MzU1IDc2LjkxMjIgNzMuNTMyMSA3Ni45MTIyQzcwLjI5MzkgNzYuOTEyMiA2OC43MDUzIDc1LjEzNDIgNjguNzA1MyA3Mi4zMTRDNjguNzA1MyA3MS41NzgzIDY4LjgyNzUgNzAuNzgxMiA2OS4wNzE5IDY5LjkyMjlDNjUuNTg5MyA3NS44Njk5IDU4Ljg2ODUgODEuMzg3OCA1Mi41NzU0IDgxLjM4NzhDNDcuOTkzIDgxLjM4NzggNDUuNjcxMyA3OC41MDYzIDQ1LjY3MTMgNzQuNDU5OEM0NS42NzEzIDcyLjk4ODQgNDUuOTc2OCA3MS40NTU2IDQ2LjUyNjcgNjkuOTIyOVpNODMuNjc2MSA0Mi41Nzk0QzgzLjY3NjEgNDYuMTcwNCA4MS41NTc1IDQ3Ljk2NTggNzkuMTg3NSA0Ny45NjU4Qzc2Ljc4MTYgNDcuOTY1OCA3NC42OTg5IDQ2LjE3MDQgNzQuNjk4OSA0Mi41Nzk0Qzc0LjY5ODkgMzguOTg4NSA3Ni43ODE2IDM3LjE5MzEgNzkuMTg3NSAzNy4xOTMxQzgxLjU1NzUgMzcuMTkzMSA4My42NzYxIDM4Ljk4ODUgODMuNjc2MSA0Mi41Nzk0Wk03MC4yMTAzIDQyLjU3OTVDNzAuMjEwMyA0Ni4xNzA0IDY4LjA5MTYgNDcuOTY1OCA2NS43MjE2IDQ3Ljk2NThDNjMuMzE1NyA0Ny45NjU4IDYxLjIzMyA0Ni4xNzA0IDYxLjIzMyA0Mi41Nzk1QzYxLjIzMyAzOC45ODg1IDYzLjMxNTcgMzcuMTkzMSA2NS43MjE2IDM3LjE5MzFDNjguMDkxNiAzNy4xOTMxIDcwLjIxMDMgMzguOTg4NSA3MC4yMTAzIDQyLjU3OTVaIiBmaWxsPSIjRkZGREY4Ii8+Cjwvc3ZnPg=='; + supportedTransactionVersions: ReadonlySet = new Set(['legacy', 0]); + + private _connecting: boolean; + private _wallet: PhantomWallet | null; + private _publicKey: PublicKey | null; + private _readyState: WalletReadyState = + typeof window === 'undefined' || typeof document === 'undefined' + ? WalletReadyState.Unsupported + : WalletReadyState.NotDetected; + + constructor(config: PhantomWalletAdapterConfig = {}) { + super(); + this._connecting = false; + this._wallet = null; + this._publicKey = null; + + if (this._readyState !== WalletReadyState.Unsupported) { + if (isIosAndRedirectable()) { + // when in iOS (not webview), set Phantom as loadable instead of checking for install + this._readyState = WalletReadyState.Loadable; + this.emit('readyStateChange', this._readyState); + } else { + scopePollingDetectionStrategy(() => { + if (window?.isPhantomInstalled && (window.phantom?.solana?.isPhantom || window.solana?.isPhantom)) { + this._readyState = WalletReadyState.Installed; + this.emit('readyStateChange', this._readyState); + return true; + } + return false; + }); + } + } + } + + get publicKey() { + return this._publicKey; + } + + get connecting() { + return this._connecting; + } + + get readyState() { + return this._readyState; + } + + async autoConnect(): Promise { + // Skip autoconnect in the Loadable state + // We can't redirect to a universal link without user input + if (this.readyState === WalletReadyState.Installed) { + await this.connect(); + } + } + + async connect(): Promise { + try { + if (this.connected || this.connecting) return; + + if (this.readyState === WalletReadyState.Loadable) { + // redirect to the Phantom /browse universal link + // this will open the current URL in the Phantom in-wallet browser + const url = encodeURIComponent(window.location.href); + const ref = encodeURIComponent(window.location.origin); + window.location.href = `https://phantom.app/ul/browse/${url}?ref=${ref}`; + return; + } + + if (this.readyState !== WalletReadyState.Installed) throw new WalletNotReadyError(); + + this._connecting = true; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const wallet = window.phantom?.solana || window.solana!; + + if (!wallet.isConnected) { + try { + await wallet.connect(); + } catch (error: any) { + throw new WalletConnectionError(error?.message, error); + } + } + + if (!wallet.publicKey) throw new WalletAccountError(); + + let publicKey: PublicKey; + try { + publicKey = new PublicKey(wallet.publicKey.toBytes()); + } catch (error: any) { + throw new WalletPublicKeyError(error?.message, error); + } + + wallet.on('disconnect', this._disconnected); + wallet.on('accountChanged', this._accountChanged); + + this._wallet = wallet; + this._publicKey = publicKey; + + this.emit('connect', publicKey); + } catch (error: any) { + this.emit('error', error); + throw error; + } finally { + this._connecting = false; + } + } + + async disconnect(): Promise { + const wallet = this._wallet; + if (wallet) { + wallet.off('disconnect', this._disconnected); + wallet.off('accountChanged', this._accountChanged); + + this._wallet = null; + this._publicKey = null; + + try { + await wallet.disconnect(); + } catch (error: any) { + this.emit('error', new WalletDisconnectionError(error?.message, error)); + } + } + + this.emit('disconnect'); + } + + async sendTransaction( + transaction: T, + connection: Connection, + options: SendTransactionOptions = {} + ): Promise { + try { + const wallet = this._wallet; + if (!wallet) throw new WalletNotConnectedError(); + + try { + const { signers, ...sendOptions } = options; + + if (isVersionedTransaction(transaction)) { + signers?.length && transaction.sign(signers); + } else { + transaction = (await this.prepareTransaction(transaction, connection, sendOptions)) as T; + signers?.length && (transaction as Transaction).partialSign(...signers); + } + + sendOptions.preflightCommitment = sendOptions.preflightCommitment || connection.commitment; + + const { signature } = await wallet.signAndSendTransaction(transaction, sendOptions); + return signature; + } catch (error: any) { + if (error instanceof WalletError) throw error; + throw new WalletSendTransactionError(error?.message, error); + } + } catch (error: any) { + this.emit('error', error); + throw error; + } + } + + async signTransaction(transaction: T): Promise { + try { + const wallet = this._wallet; + if (!wallet) throw new WalletNotConnectedError(); + + try { + return (await wallet.signTransaction(transaction)) || transaction; + } catch (error: any) { + throw new WalletSignTransactionError(error?.message, error); + } + } catch (error: any) { + this.emit('error', error); + throw error; + } + } + + async signAllTransactions(transactions: T[]): Promise { + try { + const wallet = this._wallet; + if (!wallet) throw new WalletNotConnectedError(); + + try { + return (await wallet.signAllTransactions(transactions)) || transactions; + } catch (error: any) { + throw new WalletSignTransactionError(error?.message, error); + } + } catch (error: any) { + this.emit('error', error); + throw error; + } + } + + async signMessage(message: Uint8Array): Promise { + try { + const wallet = this._wallet; + if (!wallet) throw new WalletNotConnectedError(); + + try { + const { signature } = await wallet.signMessage(message); + return signature; + } catch (error: any) { + throw new WalletSignMessageError(error?.message, error); + } + } catch (error: any) { + this.emit('error', error); + throw error; + } + } + + private _disconnected = () => { + const wallet = this._wallet; + if (wallet) { + wallet.off('disconnect', this._disconnected); + wallet.off('accountChanged', this._accountChanged); + + this._wallet = null; + this._publicKey = null; + + this.emit('error', new WalletDisconnectedError()); + this.emit('disconnect'); + } + }; + + private _accountChanged = (newPublicKey: PublicKey) => { + const publicKey = this._publicKey; + if (!publicKey) return; + + try { + newPublicKey = new PublicKey(newPublicKey.toBytes()); + } catch (error: any) { + this.emit('error', new WalletPublicKeyError(error?.message, error)); + return; + } + + if (publicKey.equals(newPublicKey)) return; + + this._publicKey = newPublicKey; + this.emit('connect', newPublicKey); + }; +} diff --git a/temp_phantom_pkg/src/index.ts b/temp_phantom_pkg/src/index.ts new file mode 100644 index 0000000..ddec7b5 --- /dev/null +++ b/temp_phantom_pkg/src/index.ts @@ -0,0 +1 @@ +export * from './adapter.js'; diff --git a/temp_phantom_pkg/tsconfig.cjs.json b/temp_phantom_pkg/tsconfig.cjs.json new file mode 100644 index 0000000..099b9aa --- /dev/null +++ b/temp_phantom_pkg/tsconfig.cjs.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig.cjs.json", + "include": ["src"], + "compilerOptions": { + "outDir": "lib/cjs" + } +} diff --git a/temp_phantom_pkg/tsconfig.esm.json b/temp_phantom_pkg/tsconfig.esm.json new file mode 100644 index 0000000..4900d2f --- /dev/null +++ b/temp_phantom_pkg/tsconfig.esm.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.esm.json", + "include": ["src"], + "compilerOptions": { + "outDir": "lib/esm", + "declarationDir": "lib/types" + } +} diff --git a/temp_phantom_pkg/tsconfig.json b/temp_phantom_pkg/tsconfig.json new file mode 100644 index 0000000..c2bd381 --- /dev/null +++ b/temp_phantom_pkg/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.root.json", + "references": [ + { + "path": "./tsconfig.cjs.json" + }, + { + "path": "./tsconfig.esm.json" + } + ] +}