Procházet zdrojové kódy

Oasis release 0.4.4

psy před 3 dny
rodič
revize
73249cf594

+ 9 - 1
README.md

@@ -60,6 +60,7 @@ Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
  + Agenda: Module to manage all your assigned items.
  + AI: Module to talk with a LLM called '42'.
  + Audios: Module to discover and manage audios.
+ + Banking: Module to distribute a fair Universal Basic Income (UBI) using commons-treasury.
  + BlockExplorer: Module to navigate the blockchain.
  + Bookmarks: Module to discover and manage bookmarks.	
  + Cipher: Module to encrypt and decrypt your text symmetrically (using a shared password).	
@@ -77,6 +78,7 @@ Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
  + Multiverse: Module to receive content from other federated peers.	
  + Opinions: Module to discover and vote on opinions.	
  + Pixelia: Module to draw on a collaborative grid.	
+ + Projects: Module to explore, crowd-funding and manage projects.
  + Popular: Module to receive posts that are trending, most viewed, or most commented on.	
  + Reports: Module to manage and track reports related to issues, bugs, abuses, and content warnings.	
  + Summaries: Module to receive summaries of long discussions or posts.	
@@ -100,7 +102,7 @@ Oasis contains its own AI model called "42".
 
 The main idea behind this implementation is to enable distributed learning generated through the collective action of many individuals, with the goal of redistributing the necessary processing load, as well as the ecological footprint and corporate bias.
 
-  ![SNH](https://solarnethub.com/git/oasis-ai-example.png "SolarNET.HuB")
+  ![SNH](https://solarnethub.com/git/oasis-ai-example2.png "SolarNET.HuB")
 
 Our AI is trained with content from the OASIS network and its purpose is to take action and obtain answers to individual, but also global, problems.
 
@@ -117,6 +119,12 @@ Oasis contains its own cryptocurrency. With it, you can exchange items and servi
 You can also receive a -Universal Basic Income- if you contribute to the Tribes and their coordinated actions.
 
  + https://ecoin.03c8.net
+ 
+## Banking (crypto-economy)
+
+Oasis contains its own UBI (Universal Basic Income), distributed weekly using ECOin, and calculated by our AI through positive and efficient participation and trust.
+
+  ![SNH](https://solarnethub.com/git/oasis-banking.png "SolarNET.HuB")
 
 ----------
 

+ 15 - 0
docs/CHANGELOG.md

@@ -13,6 +13,21 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+## v0.4.4 - 2025-08-17
+
+### Added
+
+ + Projects: Module to explore, crowd-funding and manage projects.
+ + Banking: Module to distribute a fair Universal Basic Income (UBI) using commons-treasury.
+ 
+### Changed
+
+- AI.
+- Activity.
+- BlockExplorer.
+- Statistics.
+- Avatar.
+
 ## v0.4.3 - 2025-08-08
 
 ### Added

+ 38 - 26
src/AI/ai_service.mjs

@@ -1,11 +1,11 @@
 import path from 'path';
+import fs from 'fs';
 import { fileURLToPath } from 'url';
 import express from '../server/node_modules/express/index.js';
 import cors from '../server/node_modules/cors/lib/index.js';
 import { getLlama, LlamaChatSession } from '../server/node_modules/node-llama-cpp/dist/index.js';
 
 let getConfig, buildAIContext;
-
 try {
   getConfig = (await import('../configs/config-manager.js')).getConfig;
 } catch {}
@@ -23,44 +23,56 @@ const __filename = fileURLToPath(import.meta.url);
 const __dirname = path.dirname(__filename);
 
 let llamaInstance, model, context, session;
+let ready = false;
+let lastError = null;
 
 async function initModel() {
-  if (!model) {
-    llamaInstance = await getLlama({ gpu: false });
-    model = await llamaInstance.loadModel({
-      modelPath: path.join(__dirname, '..', 'AI', 'oasis-42-1-chat.Q4_K_M.gguf')
-    });
-    context = await model.createContext();
-    session = new LlamaChatSession({ contextSequence: context.getSequence() });
+  if (model) return;
+  const modelPath = path.join(__dirname, 'oasis-42-1-chat.Q4_K_M.gguf');
+  if (!fs.existsSync(modelPath)) {
+    throw new Error(`Model file not found at: ${modelPath}`);
   }
+  llamaInstance = await getLlama({ gpu: false });
+  model = await llamaInstance.loadModel({ modelPath });
+  context = await model.createContext();
+  session = new LlamaChatSession({ contextSequence: context.getSequence() });
+  ready = true;
 }
 
 app.post('/ai', async (req, res) => {
   try {
-    const userInput = req.body.input;
+    const userInput = String(req.body.input || '').trim();
     await initModel();
+
     let userContext = '';
+    let snippets = [];
     try {
-      userContext = await buildAIContext();
-    } catch {
-      userContext = '';
-    }
+      userContext = await (buildAIContext ? buildAIContext(120) : '');
+      if (userContext) {
+        snippets = userContext.split('\n').slice(0, 50);
+      }
+    } catch {}
+
     const config = getConfig?.() || {};
-    const userPrompt = config.ai?.prompt?.trim() || "Provide an informative and precise response.";
-    const promptParts = [
-      "Context: You are an AI assistant called \"42\" in Oasis, a distributed, encrypted and federated social network.",
-    ];
-    if (userContext?.trim()) {
-      promptParts.push(`User Data:\n${userContext}`);
-    }
-    promptParts.push(`Query: "${userInput}"`);
-    promptParts.push(userPrompt);
-    const finalPrompt = promptParts.join('\n\n');
-    const response = await session.prompt(finalPrompt);
-    res.json({ answer: response.trim() });
+    const userPrompt = config.ai?.prompt?.trim() || 'Provide an informative and precise response.';
+
+    const prompt = [
+      'Context: You are an AI assistant called "42" in Oasis, a distributed, encrypted and federated social network.',
+      userContext ? `User Data:\n${userContext}` : '',
+      `Query: "${userInput}"`,
+      userPrompt
+    ].filter(Boolean).join('\n\n');
+    const answer = await session.prompt(prompt);
+    res.json({ answer: String(answer || '').trim(), snippets });
   } catch (err) {
-    res.status(500).json({ error: 'Internal Server Error', details: err.message });
+    lastError = err;
+    res.status(500).json({ error: 'Internal Server Error', details: String(err.message || err) });
   }
 });
 
+app.post('/ai/train', async (req, res) => {
+  res.json({ stored: true });
+});
+
 app.listen(4001);
+

+ 79 - 79
src/AI/buildAIContext.js

@@ -1,124 +1,124 @@
-import pull from 'pull-stream';
-import gui from '../client/gui.js';
+const pull = require('../server/node_modules/pull-stream');
+const gui = require('../client/gui.js');
 const { getConfig } = require('../configs/config-manager.js');
-const logLimit = getConfig().ssbLogStream?.limit || 1000;
+const path = require('path');
 
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
 const cooler = gui({ offline: false });
 
 const searchableTypes = [
   'post', 'about', 'curriculum', 'tribe', 'transfer', 'feed',
-  'votes', 'report', 'task', 'event', 'bookmark', 'document',
-  'image', 'audio', 'video', 'market'
+  'votes', 'vote', 'report', 'task', 'event', 'bookmark', 'document',
+  'image', 'audio', 'video', 'market', 'forum', 'job', 'project',
+  'contact', 'pub', 'pixelia', 'bankWallet', 'bankClaim', 'aiExchange'
 ];
 
-const getRelevantFields = (type, content) => {
+const clip = (s, n) => String(s || '').slice(0, n);
+const squash = s => String(s || '').replace(/\s+/g, ' ').trim();
+const compact = s => squash(clip(s, 160));
+
+function fieldsForSnippet(type, c) {
   switch (type) {
-    case 'post':
-      return [content?.text, content?.contentWarning, ...(content?.tags || [])];
-    case 'about':
-      return [content?.about, content?.name, content?.description];
-    case 'feed':
-      return [content?.text, content?.author, content?.createdAt, ...(content?.tags || []), content?.refeeds];
-    case 'event':
-      return [content?.title, content?.description, content?.date, content?.location, content?.price, ...(content?.tags || [])];
-    case 'votes':
-      return [content?.question, content?.deadline, content?.status, content?.totalVotes];
-    case 'tribe':
-      return [content?.title, content?.description, content?.location, content?.members?.length, ...(content?.tags || [])];
-    case 'audio':
-      return [content?.title, content?.description, ...(content?.tags || [])];
-    case 'image':
-      return [content?.title, content?.description, ...(content?.tags || [])];
-    case 'video':
-      return [content?.title, content?.description, ...(content?.tags || [])];
-    case 'document':
-      return [content?.title, content?.description, ...(content?.tags || [])];
-    case 'market':
-      return [content?.title, content?.description, content?.price, content?.status, ...(content?.tags || [])];
-    case 'bookmark':
-      return [content?.url, content?.description, ...(content?.tags || [])];
-    case 'task':
-      return [content?.title, content?.description, content?.status, ...(content?.tags || [])];
-    case 'report':
-      return [content?.title, content?.description, content?.severity, content?.status, ...(content?.tags || [])];
-    case 'transfer':
-      return [content?.from, content?.to, content?.amount, content?.status, ...(content?.tags || [])];
-    case 'curriculum':
-      return [content?.name, content?.description, content?.location, content?.status, ...(content?.personalSkills || []), ...(content?.languages || [])];
-    default:
-      return [];
+    case 'aiExchange': return [c?.question, clip(squash(c?.answer || ''), 120)];
+    case 'post': return [c?.text, ...(c?.tags || [])];
+    case 'about': return [c?.about, c?.name, c?.description];
+    case 'curriculum': return [c?.name, c?.description, c?.location];
+    case 'tribe': return [c?.title, c?.description, ...(c?.tags || [])];
+    case 'transfer': return [c?.from, c?.to, String(c?.amount), c?.status];
+    case 'feed': return [c?.text, ...(c?.tags || [])];
+    case 'votes': return [c?.question, c?.status];
+    case 'vote': return [c?.vote?.link, String(c?.vote?.value)];
+    case 'report': return [c?.title, c?.severity, c?.status];
+    case 'task': return [c?.title, c?.status];
+    case 'event': return [c?.title, c?.date, c?.location];
+    case 'bookmark': return [c?.url, c?.description];
+    case 'document': return [c?.title, c?.description];
+    case 'image': return [c?.title, c?.description];
+    case 'audio': return [c?.title, c?.description];
+    case 'video': return [c?.title, c?.description];
+    case 'market': return [c?.title, String(c?.price), c?.status];
+    case 'forum': return [c?.title, c?.category, c?.text];
+    case 'job': return [c?.title, c?.job_type, String(c?.salary), c?.status];
+    case 'project': return [c?.title, c?.status, String(c?.progress)];
+    case 'contact': return [c?.contact];
+    case 'pub': return [c?.address?.key, c?.address?.host];
+    case 'pixelia': return [c?.author];
+    case 'bankWallet': return [c?.address];
+    case 'bankClaim': return [String(c?.amount), c?.epochId, c?.txid];
+    default: return [];
   }
-};
+}
+
+async function publishExchange({ q, a, ctx = [], tokens = {} }) {
+  const ssbClient = await cooler.open();
+
+  const content = {
+    type: 'aiExchange',
+    question: clip(String(q || ''), 2000),
+    answer: clip(String(a || ''), 5000),
+    ctx: ctx.slice(0, 12).map(s => clip(String(s || ''), 800)),
+    timestamp: Date.now()
+  };
+
+  return new Promise((resolve, reject) => {
+    ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res));
+  });
+}
 
 async function buildContext(maxItems = 100) {
   const ssb = await cooler.open();
   return new Promise((resolve, reject) => {
     pull(
-      ssb.createLogStream({ limit: logLimit }),
+      ssb.createLogStream({ reverse: true, limit: logLimit }),
       pull.collect((err, msgs) => {
         if (err) return reject(err);
 
         const tombstoned = new Set();
         const latest = new Map();
-        const users = new Set();
-        const events = [];
 
-        msgs.forEach(({ key, value }) => {
-          if (value.content.type === 'tombstone') tombstoned.add(value.content.target);
-        });
+        for (const { value } of msgs) {
+          const c = value?.content;
+          if (c?.type === 'tombstone' && c?.target) tombstoned.add(c.target);
+        }
 
-        msgs.forEach(({ key, value }) => {
-          const { author, content, timestamp } = value;
+        for (const { key, value } of msgs) {
+          const author = value?.author;
+          const content = value?.content || {};
           const type = content?.type;
-          if (!searchableTypes.includes(type) || tombstoned.has(key)) return;
+          const ts = value?.timestamp || 0;
 
-          users.add(author);
-          if (type === 'event' && new Date(content.date) >= new Date()) events.push({ content, timestamp });
+          if (!searchableTypes.includes(type) || tombstoned.has(key)) continue;
 
           const uniqueKey = type === 'about' ? content.about : key;
-          if (!latest.has(uniqueKey) || latest.get(uniqueKey).value.timestamp < timestamp) {
+          if (!latest.has(uniqueKey) || (latest.get(uniqueKey)?.value?.timestamp || 0) < ts) {
             latest.set(uniqueKey, { key, value });
           }
-        });
-
-        events.sort((a, b) => new Date(a.content.date) - new Date(b.content.date));
+        }
 
         const grouped = {};
         Array.from(latest.values())
-          .sort((a, b) => b.value.timestamp - a.value.timestamp)
+          .sort((a, b) => (b.value.timestamp || 0) - (a.value.timestamp || 0))
           .slice(0, maxItems)
           .forEach(({ value }) => {
-            const { content, timestamp } = value;
-            const fields = getRelevantFields(content.type, content).filter(Boolean).join(' | ');
+            const content = value.content;
+            const type = content.type;
+            const fields = fieldsForSnippet(type, content).filter(Boolean).map(compact).filter(Boolean).join(' | ');
             if (!fields) return;
-
-            const date = new Date(timestamp).toISOString().slice(0, 10);
-            grouped[content.type] = grouped[content.type] || [];
-            grouped[content.type].push(`[${date}] (${content.type}) ${fields}`);
+            const date = new Date(value.timestamp || 0).toISOString().slice(0, 10);
+            grouped[type] = grouped[type] || [];
+            grouped[type].push(`[${date}] (${type}) ${fields}`);
           });
 
-        const summary = [`## SUMMARY`, `Total Users: ${users.size}`];
-        if (events.length) {
-          const nextEvent = events[0].content;
-          summary.push(`Next Event: "${nextEvent.title}" on ${nextEvent.date} at ${nextEvent.location}`);
-        }
-
-        const upcomingEvents = events.map(({ content }) => `[${content.date}] ${content.title} | ${content.location}`).join('\n');
-
         const contextSections = Object.entries(grouped)
-          .map(([type, lines]) => `## ${type.toUpperCase()}\n\n${lines.join('\n')}`)
+          .map(([type, lines]) => `## ${type.toUpperCase()}\n\n${lines.slice(0, 20).join('\n')}`)
           .join('\n\n');
 
-        const finalContext = [
-          summary.join('\n'),
-          events.length ? `## UPCOMING EVENTS\n\n${upcomingEvents}` : '',
-          contextSections
-        ].filter(Boolean).join('\n\n');
-
+        const finalContext = contextSections ? contextSections : '';
         resolve(finalContext);
       })
     );
   });
 }
 
-export default buildContext;
+module.exports = { fieldsForSnippet, buildContext, clip, publishExchange };
+

+ 614 - 92
src/backend/backend.js

@@ -42,16 +42,42 @@ if (config.debug) {
 }
 
 //AI
+const axiosMod = require('../server/node_modules/axios');
+const axios = axiosMod.default || axiosMod;
 const { spawn } = require('child_process');
+
+const { fieldsForSnippet, buildContext, clip, publishExchange } = require('../AI/buildAIContext.js');
+
+let aiStarted = false;
 function startAI() {
-  const aiPath = path.resolve(__dirname, '../AI/ai_service.mjs');
-  const aiProcess = spawn('node', [aiPath], {
-    detached: false,
-    stdio: 'ignore', //inherit for debug
-  });
-  aiProcess.unref();
+    if (aiStarted) return;
+    aiStarted = true;
+    const aiPath = path.resolve(__dirname, '../AI/ai_service.mjs');
+    const aiProcess = spawn('node', [aiPath], {
+        detached: true,
+        stdio: 'ignore' // set 'inherit' for debug
+    });
+    aiProcess.unref();
+}
+
+//banking
+function readWalletMap() {
+  const candidates = [
+    path.join(__dirname, '..', 'configs', 'wallet-addresses.json'),
+    path.join(process.cwd(), 'configs', 'wallet-addresses.json')
+  ];
+  for (const p of candidates) {
+    try {
+      if (fs.existsSync(p)) {
+        const obj = JSON.parse(fs.readFileSync(p, 'utf8'));
+        if (obj && typeof obj === 'object') return obj;
+      }
+    } catch {}
+  }
+  return {};
 }
 
+//custom styles
 const customStyleFile = path.join(
   envPaths("oasis", { suffix: "" }).config,
   "/custom-style.css"
@@ -212,6 +238,8 @@ const marketModel = require('../models/market_model')({ cooler, isPublic: config
 const forumModel = require('../models/forum_model')({ cooler, isPublic: config.public });
 const blockchainModel = require('../models/blockchain_model')({ cooler, isPublic: config.public });
 const jobsModel = require('../models/jobs_model')({ cooler, isPublic: config.public });
+const projectsModel = require("../models/projects_model")({ cooler, isPublic: config.public });
+const bankingModel = require("../models/banking_model")({ cooler, isPublic: config.public });
 
 // starting warmup
 about._startNameWarmup();
@@ -256,6 +284,14 @@ async function renderBlobMarkdown(text, mentions = {}, myFeedId, myUsername) {
 }
 
 let formattedTextCache = null; 
+const ADDR_PATH = path.join(__dirname, "..", "configs", "wallet-addresses.json");
+function readAddrMap() {
+  try { return JSON.parse(fs.readFileSync(ADDR_PATH, "utf8")); } catch { return {}; }
+}
+function writeAddrMap(map) {
+  fs.mkdirSync(path.dirname(ADDR_PATH), { recursive: true });
+  fs.writeFileSync(ADDR_PATH, JSON.stringify(map, null, 2));
+}
 
 const preparePreview = async function (ctx) {
   let text = String(ctx.request.body.text || "");
@@ -414,6 +450,8 @@ const { aiView } = require("../views/AI_view");
 const { forumView, singleForumView } = require("../views/forum_view");
 const { renderBlockchainView, renderSingleBlockView } = require("../views/blockchain_view");
 const { jobsView, singleJobsView, renderJobForm } = require("../views/jobs_view");
+const { projectsView, singleProjectView } = require("../views/projects_view")
+const { renderBankingView, renderSingleAllocationView, renderEpochView } = require("../views/banking_views")
 
 let sharp;
 
@@ -473,9 +511,16 @@ router
   .get(oasisCheckPath, (ctx) => {
     ctx.body = "oasis";
   })
-  .get('/stats', async ctx => {
+  .get('/stats', async (ctx) => {
     const filter = ctx.query.filter || 'ALL';
     const stats = await statsModel.getStats(filter);
+    const myId = SSBconfig.config.keys.id;
+    const myAddress = await bankingModel.getUserAddress(myId);
+    const addrRows = await bankingModel.listAddressesMerged();
+    stats.banking = {
+      myAddress: myAddress || null,
+      totalAddresses: Array.isArray(addrRows) ? addrRows.length : 0
+    };
     ctx.body = statsView(stats, filter);
   })
   .get("/public/popular/:period", async (ctx) => {
@@ -531,7 +576,7 @@ router
     'popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 
     'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'trending', 
     'events', 'tasks', 'market', 'tribes', 'governance', 'reports', 'opinions', 'transfers', 
-    'feed', 'pixelia', 'agenda', 'ai', 'forum', 'jobs'
+    'feed', 'pixelia', 'agenda', 'ai', 'forum', 'jobs', 'projects', 'banking'
     ];
     const moduleStates = modules.reduce((acc, mod) => {
       acc[`${mod}Mod`] = configMods[`${mod}Mod`];
@@ -541,20 +586,23 @@ router
   })
    // AI
   .get('/ai', async (ctx) => {
-    const aiMod = ctx.cookies.get("aiMod") || 'on';
+    const aiMod = ctx.cookies.get('aiMod') || 'on';
     if (aiMod !== 'on') {
-      ctx.redirect('/modules');
-      return;
+        ctx.redirect('/modules');
+        return;
     }
     startAI();
+    const i18nAll = require('../client/assets/translations/i18n');
+    const lang = ctx.cookies.get('lang') || 'en';
+    const translations = i18nAll[lang] || i18nAll['en'];
+    const { setLanguage } = require('../views/main_views');
+    setLanguage(lang);
     const historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json');
     let chatHistory = [];
     try {
-      const fileData = fs.readFileSync(historyPath, 'utf-8');
-      chatHistory = JSON.parse(fileData);
-    } catch (e) {
-      chatHistory = [];
-    }
+        const fileData = fs.readFileSync(historyPath, 'utf-8');
+        chatHistory = JSON.parse(fileData);
+    } catch {}
     const config = getConfig();
     const userPrompt = config.ai?.prompt?.trim() || '';
     ctx.body = aiView(chatHistory, userPrompt);
@@ -632,33 +680,31 @@ router
     const messages = await post.latestThreads();
     ctx.body = await threadsView({ messages });
   })
-  .get("/author/:feed", async (ctx) => {
-    const { feed } = ctx.params;
-    const gt = Number(ctx.request.query["gt"] || -1);
-    const lt = Number(ctx.request.query["lt"] || -1);
-    if (lt > 0 && gt > 0 && gt >= lt)
-      throw new Error("Given search range is empty");
-    const author = async (feedId) => {
-      const description = await about.description(feedId);
-      const name = await about.name(feedId);
-      const image = await about.image(feedId);
-      const messages = await post.fromPublicFeed(feedId, gt, lt);
-      const firstPost = await post.firstBy(feedId);
-      const lastPost = await post.latestBy(feedId);
-      const relationship = await friend.getRelationship(feedId);
-      const avatarUrl = getAvatarUrl(image);
-      return authorView({
-        feedId,
-        messages,
-        firstPost,
-        lastPost,
-        name,
-        description,
-        avatarUrl,
-        relationship,
-      });
-    };
-    ctx.body = await author(feed);
+  .get('/author/:feed', async (ctx) => {
+    const feedId = decodeURIComponent(ctx.params.feed || '');
+    const gt = Number(ctx.request.query.gt || -1);
+    const lt = Number(ctx.request.query.lt || -1);
+    if (lt > 0 && gt > 0 && gt >= lt) throw new Error('Given search range is empty');
+    const description = await about.description(feedId);
+    const name = await about.name(feedId);
+    const image = await about.image(feedId);
+    const messages = await post.fromPublicFeed(feedId, gt, lt);
+    const firstPost = await post.firstBy(feedId);
+    const lastPost = await post.latestBy(feedId);
+    const relationship = await friend.getRelationship(feedId);
+    const avatarUrl = getAvatarUrl(image);
+    const ecoAddress = await bankingModel.getUserAddress(feedId);
+    ctx.body = authorView({
+      feedId,
+      messages,
+      firstPost,
+      lastPost,
+      name,
+      description,
+      avatarUrl,
+      relationship,
+      ecoAddress
+    });
   })
   .get("/search", async (ctx) => {
     const query = ctx.query.query || '';
@@ -892,18 +938,18 @@ router
     ctx.body = activityView(actions, filter, userId);
   })
   .get("/profile", async (ctx) => {
-    const myFeedId = await meta.myFeedId();
-    const gt = Number(ctx.request.query["gt"] || -1);
-    const lt = Number(ctx.request.query["lt"] || -1);
-    if (lt > 0 && gt > 0 && gt >= lt)
-      throw new Error("Given search range is empty");
-    const description = await about.description(myFeedId);
-    const name = await about.name(myFeedId);
-    const image = await about.image(myFeedId);
-    const messages = await post.fromPublicFeed(myFeedId, gt, lt);
-    const firstPost = await post.firstBy(myFeedId);
-    const lastPost = await post.latestBy(myFeedId);
-    const avatarUrl = getAvatarUrl(image);
+    const myFeedId = await meta.myFeedId()
+    const gt = Number(ctx.request.query["gt"] || -1)
+    const lt = Number(ctx.request.query["lt"] || -1)
+    if (lt > 0 && gt > 0 && gt >= lt) throw new Error("Given search range is empty")
+    const description = await about.description(myFeedId)
+    const name = await about.name(myFeedId)
+    const image = await about.image(myFeedId)
+    const messages = await post.fromPublicFeed(myFeedId, gt, lt)
+    const firstPost = await post.firstBy(myFeedId)
+    const lastPost = await post.latestBy(myFeedId)
+    const avatarUrl = getAvatarUrl(image)
+    const ecoAddress = await bankingModel.getUserAddress(myFeedId)
     ctx.body = await authorView({
       feedId: myFeedId,
       messages,
@@ -913,7 +959,8 @@ router
       description,
       avatarUrl,
       relationship: { me: true },
-    });
+      ecoAddress
+    })
   })
   .get("/profile/edit", async (ctx) => {
     const myFeedId = await meta.myFeedId();
@@ -1021,14 +1068,26 @@ router
     const theme = ctx.cookies.get("theme") || "Dark-SNH";
     const config = getConfig();
     const aiPrompt = config.ai?.prompt || "";
-    const getMeta = async ({ theme, aiPrompt }) => {
+    const pubWalletUrl = config.walletPub?.url || '';
+    const pubWalletUser = config.walletPub?.user || '';
+    const pubWalletPass = config.walletPub?.pass || '';
+    const getMeta = async ({ theme, aiPrompt, pubWalletUrl, pubWalletUser, pubWalletPass }) => {
       return settingsView({
         theme,
         version: version.toString(),
-        aiPrompt
+        aiPrompt,
+        pubWalletUrl, 
+        pubWalletUser,
+        pubWalletPass
       });
     };
-    ctx.body = await getMeta({ theme, aiPrompt });
+    ctx.body = await getMeta({ 
+      theme, 
+      aiPrompt, 
+      pubWalletUrl, 
+      pubWalletUser, 
+      pubWalletPass 
+    });
   })
   .get("/peers", async (ctx) => {
     const theme = ctx.cookies.get("theme") || config.theme;
@@ -1283,6 +1342,61 @@ router
     const job = await jobsModel.getJobById(jobId);
     ctx.body = await singleJobsView(job, filter);
   })
+  .get('/projects', async (ctx) => {
+    const projectsMod = ctx.cookies.get("projectsMod") || 'on'
+    if (projectsMod !== 'on') { ctx.redirect('/modules'); return }
+    const filter = ctx.query.filter || 'ALL'
+    if (filter === 'CREATE') {
+      ctx.body = await projectsView([], 'CREATE'); return
+    }
+    const modelFilter = (filter === 'BACKERS') ? 'ALL' : filter
+    const projects = await projectsModel.listProjects(modelFilter)
+    ctx.body = await projectsView(projects, filter)
+  })
+  .get('/projects/edit/:id', async (ctx) => {
+    const id = ctx.params.id
+    const pr = await projectsModel.getProjectById(id)
+    ctx.body = await projectsView([pr], 'EDIT')
+  })
+  .get('/projects/:projectId', async (ctx) => {
+    const projectId = ctx.params.projectId
+    const filter = ctx.query.filter || 'ALL'
+    const project = await projectsModel.getProjectById(projectId)
+    ctx.body = await singleProjectView(project, filter)
+  })
+  .get('/banking', async (ctx) => {
+    const bankingMod = ctx.cookies.get("bankingMod") || 'on';
+    if (bankingMod !== 'on') { 
+      ctx.redirect('/modules'); 
+      return; 
+    }
+    const userId = SSBconfig.config.keys.id;
+    const query = ctx.query;
+    const filter = (query.filter || 'overview').toLowerCase();
+    const q = (query.q || '').trim();
+    const msg = (query.msg || '').trim();
+    await bankingModel.ensureSelfAddressPublished();
+    const data = await bankingModel.listBanking(filter, userId);
+    if (filter === 'addresses' && q) {
+      data.addresses = (data.addresses || []).filter(x =>
+        String(x.id).toLowerCase().includes(q.toLowerCase()) ||
+        String(x.address).toLowerCase().includes(q.toLowerCase())
+      );
+      data.search = q;
+    }
+    data.flash = msg || '';
+    ctx.body = renderBankingView(data, filter, userId);
+  })
+  .get("/banking/allocation/:id", async (ctx) => {
+    const userId = SSBconfig.config.keys.id;
+    const allocation = await bankingModel.getAllocationById(ctx.params.id);
+    ctx.body = renderSingleAllocationView(allocation, userId);
+  })
+  .get("/banking/epoch/:id", async (ctx) => {
+    const epoch = await bankingModel.getEpochById(ctx.params.id);
+    const allocations = await bankingModel.listEpochAllocations(ctx.params.id);
+    ctx.body = renderEpochView(epoch, allocations);
+  })
   .get('/cipher', async (ctx) => {
     const cipherMod = ctx.cookies.get("cipherMod") || 'on';
     if (cipherMod !== 'on') {
@@ -1322,13 +1436,20 @@ router
   .get("/wallet", async (ctx) => {
     const { url, user, pass } = getConfig().wallet;
     const walletMod = ctx.cookies.get("walletMod") || 'on';
-    if (walletMod !== 'on') {
-      ctx.redirect('/modules');
-      return;
-    }
+    if (walletMod !== 'on') { ctx.redirect('/modules'); return; }
     try {
       const balance = await walletModel.getBalance(url, user, pass);
       const address = await walletModel.getAddress(url, user, pass);
+      const userId = SSBconfig.config.keys.id;
+      if (address && typeof address === "string") {
+        const map = readAddrMap();
+        const was = map[userId];
+        if (was !== address) {
+          map[userId] = address;
+          writeAddrMap(map);
+          try { await publishActivity({ type: 'bankWallet', address }); } catch (e) {}
+        }
+      }
       ctx.body = await walletView(balance, address);
     } catch (error) {
       ctx.body = await walletErrorView(error);
@@ -1340,6 +1461,16 @@ router
       const balance = await walletModel.getBalance(url, user, pass);
       const transactions = await walletModel.listTransactions(url, user, pass);
       const address = await walletModel.getAddress(url, user, pass);
+      const userId = SSBconfig.config.keys.id;
+      if (address && typeof address === "string") {
+        const map = readAddrMap();
+        const was = map[userId];
+        if (was !== address) {
+          map[userId] = address;
+          writeAddrMap(map);
+          try { await publishActivity({ type: 'bankWallet', address }); } catch (e) {}
+        }
+      }
       ctx.body = await walletHistoryView(balance, transactions, address);
     } catch (error) {
       ctx.body = await walletErrorView(error);
@@ -1350,6 +1481,16 @@ router
     try {
       const balance = await walletModel.getBalance(url, user, pass);
       const address = await walletModel.getAddress(url, user, pass);
+      const userId = SSBconfig.config.keys.id;
+      if (address && typeof address === "string") {
+        const map = readAddrMap();
+        const was = map[userId];
+        if (was !== address) {
+          map[userId] = address;
+          writeAddrMap(map);
+          try { await publishActivity({ type: 'bankWallet', address }); } catch (e) {}
+        }
+      }
       ctx.body = await walletReceiveView(balance, address);
     } catch (error) {
       ctx.body = await walletErrorView(error);
@@ -1360,6 +1501,17 @@ router
     try {
       const balance = await walletModel.getBalance(url, user, pass);
       const address = await walletModel.getAddress(url, user, pass);
+
+      const userId = SSBconfig.config.keys.id;
+      if (address && typeof address === "string") {
+        const map = readAddrMap();
+        const was = map[userId];
+        if (was !== address) {
+          map[userId] = address;
+          writeAddrMap(map);
+          try { await publishActivity({ type: 'bankWallet', address }); } catch (e) {}
+        }
+      }
       ctx.body = await walletSendFormView(balance, null, null, fee, null, address);
     } catch (error) {
       ctx.body = await walletErrorView(error);
@@ -1383,39 +1535,119 @@ router
 
   //POST backend routes   
   .post('/ai', koaBody(), async (ctx) => {
-    const axios = require('../server/node_modules/axios').default;
     const { input } = ctx.request.body;
     if (!input) {
-      ctx.status = 400;
-      ctx.body = { error: 'No input provided' };
-      return;
+        ctx.status = 400;
+        ctx.body = { error: 'No input provided' };
+        return;
     }
-    const config = getConfig();
-    const userPrompt = config.ai?.prompt?.trim() || "Provide an informative and precise response.";
-    const response = await axios.post('http://localhost:4001/ai', { input });
-    const aiResponse = response.data.answer;
+    startAI();
+    const i18nAll = require('../client/assets/translations/i18n');
+    const lang = ctx.cookies.get('lang') || 'en';
+    const translations = i18nAll[lang] || i18nAll['en'];
+    const { setLanguage } = require('../views/main_views');
+    setLanguage(lang);
     const historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json');
     let chatHistory = [];
     try {
-      const fileData = fs.readFileSync(historyPath, 'utf-8');
-      chatHistory = JSON.parse(fileData);
+        const fileData = fs.readFileSync(historyPath, 'utf-8');
+        chatHistory = JSON.parse(fileData);
+    } catch {
+        chatHistory = [];
+    }
+    const config = getConfig();
+    const userPrompt = config.ai?.prompt?.trim() || 'Provide an informative and precise response.';
+    try {
+        const response = await axios.post('http://localhost:4001/ai', { input });
+        const aiResponse = response.data.answer;
+        const snippets = Array.isArray(response.data.snippets) ? response.data.snippets : [];
+        chatHistory.unshift({
+            prompt: userPrompt,
+            question: input,
+            answer: aiResponse,
+            timestamp: Date.now(),
+            trainStatus: 'pending',
+            snippets
+        });
     } catch (e) {
-      chatHistory = [];
+        chatHistory.unshift({
+            prompt: userPrompt,
+            question: input,
+            answer: translations.aiServerError || 'The AI could not answer. Please try again.',
+            timestamp: Date.now(),
+            trainStatus: 'rejected',
+            snippets: []
+        });
     }
-    chatHistory.unshift({
-      prompt: userPrompt,
-      question: input,
-      answer: aiResponse,
-      timestamp: Date.now()
-    });
     chatHistory = chatHistory.slice(0, 20);
     fs.writeFileSync(historyPath, JSON.stringify(chatHistory, null, 2), 'utf-8');
     ctx.body = aiView(chatHistory, userPrompt);
   })
+  .post('/ai/approve', koaBody(), async (ctx) => {
+    const ts = String(ctx.request.body.ts || '');
+    const historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json');
+    let chatHistory = [];
+    try {
+        const fileData = fs.readFileSync(historyPath, 'utf-8');
+        chatHistory = JSON.parse(fileData);
+    } catch (err) {
+        chatHistory = [];
+    }
+    const item = chatHistory.find(e => String(e.timestamp) === ts);
+    if (item) {
+        try {
+            const contentType = item?.type || 'aiExchange';
+            let snippets = fieldsForSnippet(contentType, item);
+            if (snippets.length === 0) {
+                const context = await buildContext();
+                snippets = [context];
+            } else {
+                snippets = snippets.map(snippet => clip(snippet, 200)); 
+            }
+            await publishExchange({
+                q: item.question,
+                a: item.answer,
+                ctx: snippets,
+                tokens: {}
+            });
+            item.trainStatus = 'approved';  
+        } catch (err) {
+            item.trainStatus = 'failed';
+        }
+        fs.writeFileSync(historyPath, JSON.stringify(chatHistory, null, 2), 'utf-8');
+    }
+    const config = getConfig();
+    const userPrompt = config.ai?.prompt?.trim() || '';
+    ctx.body = aiView(chatHistory, userPrompt);
+  })
+  .post('/ai/reject', koaBody(), async (ctx) => {
+    const i18nAll = require('../client/assets/translations/i18n');
+    const lang = ctx.cookies.get('lang') || 'en';
+    const { setLanguage } = require('../views/main_views');
+    setLanguage(lang);
+    const ts = String(ctx.request.body.ts || '');
+    const historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json');
+    let chatHistory = [];
+    try {
+        const fileData = fs.readFileSync(historyPath, 'utf-8');
+        chatHistory = JSON.parse(fileData);
+    } catch {
+        chatHistory = [];
+    }
+    const item = chatHistory.find(e => String(e.timestamp) === ts);
+    if (item) {
+        item.trainStatus = 'rejected';
+        fs.writeFileSync(historyPath, JSON.stringify(chatHistory, null, 2), 'utf-8');
+    }
+    const config = getConfig();
+    const userPrompt = config.ai?.prompt?.trim() || '';
+    ctx.body = aiView(chatHistory, userPrompt);  
+  })
   .post('/ai/clear', async (ctx) => {
-    const fs = require('fs');
-    const path = require('path');
-    const { getConfig } = require('../configs/config-manager.js');
+    const i18nAll = require('../client/assets/translations/i18n');
+    const lang = ctx.cookies.get('lang') || 'en';
+    const { setLanguage } = require('../views/main_views');
+    setLanguage(lang);
     const historyPath = path.join(__dirname, '..', '..', 'src', 'configs', 'AI-history.json');
     fs.writeFileSync(historyPath, '[]', 'utf-8');
     const config = getConfig();
@@ -2253,10 +2485,10 @@ router
     const marketItem = await marketModel.getItemById(id);
     if (marketItem.item_type === 'exchange') {
       if (marketItem.status !== 'SOLD') {
-        const buyerId = ctx.request.body.buyerId;
+        const buyerId = SSBconfig.config.keys.id;
         const { price, title, seller } = marketItem;
-        const subject = `Your item "${title}" has been sold`;
-        const text = `The item with title: "${title}" has been sold. The buyer with OASIS ID: ${buyerId} purchased it for: $${price}.`;
+        const subject = `MARKET_SOLD`;
+        const text = `item "${title}" has been sold -> /market/${id}  OASIS ID: ${buyerId}  for: $${price}`;
         await pmModel.sendMessage([seller], subject, text);
         await marketModel.setItemAsSold(id);
       }
@@ -2350,15 +2582,292 @@ router
     ctx.redirect('/jobs?filter=MINE');
   })
   .post('/jobs/subscribe/:id', koaBody(), async (ctx) => {
-    const id = ctx.params.id;
-    await jobsModel.subscribeToJob(id, config.keys.id);
+    const rawId = ctx.params.id;
+    const userId = SSBconfig.config.keys.id;
+    const latestId = await jobsModel.getJobTipId(rawId);
+    const job = await jobsModel.getJobById(latestId);
+    const subs = Array.isArray(job.subscribers) ? job.subscribers.slice() : [];
+    if (!subs.includes(userId)) subs.push(userId);
+    await jobsModel.updateJob(latestId, { subscribers: subs });
+    const subject = 'JOB_SUBSCRIBED';
+    const title = job.title || '';
+    const text = `has subscribed to your job offer "${title}" -> /jobs/${latestId}`;
+    await pmModel.sendMessage([job.author], subject, text);
     ctx.redirect('/jobs');
   })
   .post('/jobs/unsubscribe/:id', koaBody(), async (ctx) => {
-    const id = ctx.params.id;
-    await jobsModel.unsubscribeFromJob(id, config.keys.id);
+    const rawId = ctx.params.id;
+    const userId = SSBconfig.config.keys.id;
+    const latestId = await jobsModel.getJobTipId(rawId);
+    const job = await jobsModel.getJobById(latestId);
+    const subs = Array.isArray(job.subscribers) ? job.subscribers.slice() : [];
+    const next = subs.filter(uid => uid !== userId);
+    await jobsModel.updateJob(latestId, { subscribers: next });
+    const subject = 'JOB_UNSUBSCRIBED';
+    const title = job.title || '';
+    const text = `has unsubscribed from your job offer "${title}" -> /jobs/${latestId}`;
+    await pmModel.sendMessage([job.author], subject, text);
     ctx.redirect('/jobs');
   })
+  .post('/projects/create', koaBody({ multipart: true }), async (ctx) => {
+    const b = ctx.request.body || {};
+    const imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null;
+    const bounties =
+        b.bountiesInput
+            ? b.bountiesInput.split('\n').filter(Boolean).map(l => {
+                const [t, a, d] = l.split('|');
+                return { title: (t || '').trim(), amount: parseFloat(a || 0) || 0, description: (d || '').trim(), milestoneIndex: null };
+            })
+            : [];
+    await projectsModel.createProject({
+        ...b,
+        title: b.title,
+        description: b.description,
+        goal: b.goal != null && b.goal !== '' ? parseFloat(b.goal) : 0,
+        deadline: b.deadline ? new Date(b.deadline).toISOString() : null,
+        progress: b.progress != null && b.progress !== '' ? parseInt(b.progress, 10) : 0,
+        bounties,
+        image: imageBlob
+    });
+    ctx.redirect('/projects?filter=MINE');
+  })
+  .post('/projects/update/:id', koaBody({ multipart: true }), async (ctx) => {
+    const id = ctx.params.id;
+    const b = ctx.request.body || {};
+    const imageBlob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : undefined;
+    await projectsModel.updateProject(id, {
+        title: b.title,
+        description: b.description,
+        goal: b.goal !== '' && b.goal != null ? parseFloat(b.goal) : undefined,
+        deadline: b.deadline ? new Date(b.deadline).toISOString() : undefined,
+        progress: b.progress !== '' && b.progress != null ? parseInt(b.progress, 10) : undefined,
+        bounties: b.bountiesInput !== undefined
+           ? b.bountiesInput.split('\n').filter(Boolean).map(l => {
+                const [t, a, d] = l.split('|');
+                return { title: (t || '').trim(), amount: parseFloat(a || 0) || 0, description: (d || '').trim(), milestoneIndex: null };
+           })
+            : undefined,
+        image: imageBlob
+    });
+    ctx.redirect('/projects?filter=MINE');
+  })
+  .post('/projects/delete/:id', koaBody(), async (ctx) => {
+    await projectsModel.deleteProject(ctx.params.id);
+    ctx.redirect('/projects?filter=MINE');
+  })
+  .post('/projects/status/:id', koaBody(), async (ctx) => {
+    await projectsModel.updateProjectStatus(ctx.params.id, String(ctx.request.body.status || '').toUpperCase());
+    ctx.redirect('/projects?filter=MINE');
+  })
+  .post('/projects/progress/:id', koaBody(), async (ctx) => {
+    const { progress } = ctx.request.body;
+    await projectsModel.updateProjectProgress(ctx.params.id, progress);
+    ctx.redirect(`/projects/${encodeURIComponent(ctx.params.id)}`);
+  })
+  .post('/projects/pledge/:id', koaBody(), async (ctx) => {
+    const rawId = ctx.params.id;
+    const latestId = await projectsModel.getProjectTipId(rawId); 
+    const { amount, milestoneOrBounty = '' } = ctx.request.body;
+    const pledgeAmount = parseFloat(amount);
+    if (isNaN(pledgeAmount) || pledgeAmount <= 0) ctx.throw(400, 'Invalid amount');
+    const userId = SSBconfig.config.keys.id;
+    const project = await projectsModel.getProjectById(latestId);
+    if (project.author === userId) ctx.throw(403, 'Authors cannot pledge to their own project');
+    let milestoneIndex = null;
+    let bountyIndex = null;
+    if (milestoneOrBounty.startsWith('milestone:')) {
+     milestoneIndex = parseInt(milestoneOrBounty.split(':')[1], 10);
+    } else if (milestoneOrBounty.startsWith('bounty:')) {
+      bountyIndex = parseInt(milestoneOrBounty.split(':')[1], 10);
+    }
+    const deadlineISO = require('../server/node_modules/moment')().add(14, 'days').toISOString();
+    const tags = ['backer-pledge', `project:${latestId}`];
+    const transfer = await transfersModel.createTransfer(
+      project.author,
+      'Project Pledge',
+      pledgeAmount,
+      deadlineISO,
+      tags
+    );
+    const transferId = transfer.key || transfer.id;
+    const backers = Array.isArray(project.backers) ? project.backers.slice() : [];
+    backers.push({
+      userId,
+      amount: pledgeAmount,
+      at: new Date().toISOString(),
+      transferId,
+      confirmed: false,
+      milestoneIndex,
+      bountyIndex
+    });
+    const pledged = (parseFloat(project.pledged || 0) || 0) + pledgeAmount;
+    const goalProgress = project.goal ? (pledged / parseFloat(project.goal)) * 100 : 0;
+    await projectsModel.updateProject(latestId, { backers, pledged, progress: goalProgress });
+    const subject = 'PROJECT_PLEDGE';
+    const title = project.title || '';
+    const text = `has pledged ${pledgeAmount} ECO to your project "${title}" -> /projects/${latestId}`;
+    await pmModel.sendMessage([project.author], subject, text);
+    ctx.redirect(`/projects/${encodeURIComponent(latestId)}`);
+  })
+  .post('/projects/confirm-transfer/:id', koaBody(), async (ctx) => {
+    const transferId = ctx.params.id;
+    const userId = SSBconfig.config.keys.id;
+    const transfer = await transfersModel.getTransferById(transferId);
+    if (transfer.to !== userId) ctx.throw(403, 'Unauthorized action');
+    const tagProject = (transfer.tags || []).find(t => String(t).startsWith('project:'));
+    if (!tagProject) ctx.throw(400, 'Missing project tag on transfer');
+    const projectId = tagProject.split(':')[1];
+    await transfersModel.confirmTransferById(transferId);
+    const project = await projectsModel.getProjectById(projectId);
+    const backers = Array.isArray(project.backers) ? project.backers.slice() : [];
+    const idx = backers.findIndex(b => b.transferId === transferId);
+    if (idx !== -1) backers[idx].confirmed = true;
+    const goalProgress = project.goal ? (parseFloat(project.pledged || 0) / parseFloat(project.goal)) * 100 : 0;
+    await projectsModel.updateProject(projectId, { backers, progress: goalProgress });
+    ctx.redirect(`/projects/${encodeURIComponent(projectId)}`);
+  })
+  .post('/projects/follow/:id', koaBody(), async (ctx) => {
+    const userId = SSBconfig.config.keys.id;
+    const rawId = ctx.params.id;
+    const latestId = await projectsModel.getProjectTipId(rawId);
+    const project = await projectsModel.getProjectById(latestId);
+    await projectsModel.followProject(rawId, userId);
+    const subject = 'PROJECT_FOLLOWED';
+    const title = project.title || '';
+    const text = `has followed your project "${title}" -> /projects/${latestId}`;
+    await pmModel.sendMessage([project.author], subject, text);
+    ctx.redirect('/projects');
+  })
+  .post('/projects/unfollow/:id', koaBody(), async (ctx) => {
+    const userId = SSBconfig.config.keys.id;
+    const rawId = ctx.params.id;
+    const latestId = await projectsModel.getProjectTipId(rawId);
+    const project = await projectsModel.getProjectById(latestId);
+    await projectsModel.unfollowProject(rawId, userId);
+    const subject = 'PROJECT_UNFOLLOWED';
+    const title = project.title || '';
+    const text = `has unfollowed your project "${title}" -> /projects/${latestId}`;
+    await pmModel.sendMessage([project.author], subject, text);
+    ctx.redirect('/projects');
+  })
+  .post('/projects/milestones/add/:id', koaBody(), async (ctx) => {
+    const { title, description, targetPercent, dueDate } = ctx.request.body;
+    await projectsModel.addMilestone(ctx.params.id, {
+        title,
+        description: description || '',
+        targetPercent: targetPercent != null && targetPercent !== '' ? parseInt(targetPercent, 10) : 0,
+        dueDate: dueDate ? new Date(dueDate).toISOString() : null
+    });
+    ctx.redirect(`/projects/${encodeURIComponent(ctx.params.id)}`);
+  })
+  .post('/projects/milestones/update/:id/:index', koaBody(), async (ctx) => {
+    const { title, description, targetPercent, dueDate, done } = ctx.request.body;
+    await projectsModel.updateMilestone(
+        ctx.params.id,
+        parseInt(ctx.params.index, 10),
+        {
+            title,
+            ...(description !== undefined ? { description } : {}),
+            targetPercent: targetPercent !== undefined && targetPercent !== '' ? parseInt(targetPercent, 10) : undefined,
+             dueDate: dueDate !== undefined ? (dueDate ? new Date(dueDate).toISOString() : null) : undefined,
+             done: done !== undefined ? !!done : undefined
+        }
+    );
+    ctx.redirect(`/projects/${encodeURIComponent(ctx.params.id)}`);
+  })
+  .post('/projects/milestones/complete/:id/:index', koaBody(), async (ctx) => {
+    const userId = SSBconfig.config.keys.id;
+    await projectsModel.completeMilestone(ctx.params.id, parseInt(ctx.params.index, 10), userId);
+    ctx.redirect(`/projects/${encodeURIComponent(ctx.params.id)}`);
+  })
+  .post('/projects/bounties/add/:id', koaBody(), async (ctx) => {
+    const { title, amount, description, milestoneIndex } = ctx.request.body;
+    await projectsModel.addBounty(ctx.params.id, {
+       title,
+       amount,
+       description,
+       milestoneIndex: (milestoneIndex === '' || milestoneIndex === undefined) ? null : parseInt(milestoneIndex, 10)
+    });
+    ctx.redirect(`/projects/${encodeURIComponent(ctx.params.id)}`);
+  })
+  .post('/projects/bounties/update/:id/:index', koaBody(), async (ctx) => {
+     const { title, amount, description, milestoneIndex, done } = ctx.request.body;
+     await projectsModel.updateBounty(
+       ctx.params.id,
+       parseInt(ctx.params.index, 10),
+       {
+         title: title !== undefined ? title : undefined,
+         amount: amount !== undefined && amount !== '' ? parseFloat(amount) : undefined,
+         description: description !== undefined ? description : undefined,
+         milestoneIndex: milestoneIndex !== undefined ? (milestoneIndex === '' ? null : parseInt(milestoneIndex, 10)) : undefined,
+         done: done !== undefined ? !!done : undefined
+       }
+     );
+     ctx.redirect(`/projects/${encodeURIComponent(ctx.params.id)}`);
+  })
+  .post('/projects/bounties/claim/:id/:index', koaBody(), async (ctx) => {
+    const userId = SSBconfig.config.keys.id;
+    await projectsModel.claimBounty(ctx.params.id, parseInt(ctx.params.index, 10), userId);
+    ctx.redirect(`/projects/${encodeURIComponent(ctx.params.id)}`);
+  })
+  .post('/projects/bounties/complete/:id/:index', koaBody(), async (ctx) => {
+    const userId = SSBconfig.config.keys.id;
+    await projectsModel.completeBounty(ctx.params.id, parseInt(ctx.params.index, 10), userId);
+    ctx.redirect(`/projects/${encodeURIComponent(ctx.params.id)}`);
+  })
+  .post("/banking/claim/:id", koaBody(), async (ctx) => {
+    const userId = SSBconfig.config.keys.id;
+    const allocationId = ctx.params.id;
+    const allocation = await bankingModel.getAllocationById(allocationId);
+    if (!allocation) {
+      ctx.body = { error: i18n.errorNoAllocation };
+      return;
+    }
+    if (allocation.to !== userId || allocation.status !== "UNCONFIRMED") {
+      ctx.body = { error: i18n.errorInvalidClaim };
+      return;
+    }
+    const pubWalletConfig = getConfig().walletPub;
+    const { url, user, pass } = pubWalletConfig;
+    const { txid } = await bankingModel.claimAllocation({
+      transferId: allocationId,
+      claimerId: userId,
+      pubWalletUrl: url,
+      pubWalletUser: user,
+      pubWalletPass: pass,
+    });
+    await bankingModel.updateAllocationStatus(allocationId, "CLOSED", txid);
+    await bankingModel.publishBankClaim({
+      amount: allocation.amount,
+      epochId: allocation.epochId,
+      allocationId: allocation.id,
+      txid,
+    });
+    ctx.redirect(`/banking?claimed=${encodeURIComponent(txid)}`);
+  })
+  .post("/banking/simulate", koaBody(), async (ctx) => {
+    const epochId = ctx.request.body?.epochId || undefined;
+    const rules = ctx.request.body?.rules || undefined;
+    const { epoch, allocations } = await bankingModel.computeEpoch({ epochId: epochId || undefined, rules });
+    ctx.body = { epoch, allocations };
+  })
+  .post("/banking/run", koaBody(), async (ctx) => {
+    const epochId = ctx.request.body?.epochId || undefined;
+    const rules = ctx.request.body?.rules || undefined;
+    const { epoch, allocations } = await bankingModel.executeEpoch({ epochId: epochId || undefined, rules });
+    ctx.body = { epoch, allocations };
+  })
+  .post("/banking/addresses", koaBody(), async (ctx) => {
+    const userId = (ctx.request.body?.userId || "").trim();
+    const address = (ctx.request.body?.address || "").trim();
+    const res = await bankingModel.addAddress({ userId, address });
+    ctx.redirect(`/banking?filter=addresses&msg=${encodeURIComponent(res.status)}`);
+  })
+  .post("/banking/addresses/delete", koaBody(), async (ctx) => {
+    const userId = (ctx.request.body?.userId || "").trim();
+    const res = await bankingModel.removeAddress({ userId });
+    ctx.redirect(`/banking?filter=addresses&msg=${encodeURIComponent(res.status)}`);
+  })
   
   // UPDATE OASIS
   .post("/update", koaBody(), async (ctx) => {
@@ -2436,7 +2945,7 @@ router
     'popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet',
     'legacy', 'cipher', 'bookmarks', 'videos', 'docs', 'audios', 'tags', 'images', 'trending',
     'events', 'tasks', 'market', 'tribes', 'governance', 'reports', 'opinions', 'transfers',
-    'feed', 'pixelia', 'agenda', 'ai', 'forum', 'jobs'
+    'feed', 'pixelia', 'agenda', 'ai', 'forum', 'jobs', 'projects', 'banking'
     ];
     const currentConfig = getConfig();
     modules.forEach(mod => {
@@ -2462,13 +2971,26 @@ router
     const referer = new URL(ctx.request.header.referer);
     ctx.redirect("/settings");
   })
+  .post("/settings/pub-wallet", koaBody(), async (ctx) => {
+    const walletUrl = String(ctx.request.body.wallet_url || "").trim();
+    const walletUser = String(ctx.request.body.wallet_user || "").trim();
+    const walletPass = String(ctx.request.body.wallet_pass || "").trim();
+    const currentConfig = getConfig();
+    currentConfig.walletPub = { 
+      url: walletUrl, 
+      user: walletUser, 
+      pass: walletPass 
+    };
+    fs.writeFileSync(configPath, JSON.stringify(currentConfig, null, 2));
+    ctx.redirect("/settings");
+  })
   .post('/transfers/create',
     koaBody(),
     async ctx => {
       const { to, concept, amount, deadline, tags } = ctx.request.body
       await transfersModel.createTransfer(to, concept, amount, deadline, tags)
       ctx.redirect('/transfers')
-    })
+  })
   .post('/transfers/update/:id',
     koaBody(),
     async ctx => {

+ 17 - 0
src/backend/wallet_addresses.js

@@ -0,0 +1,17 @@
+const fs = require("fs");
+const path = require("path");
+
+const STORAGE_DIR = path.join(__dirname, "..", "configs");
+const ADDR_PATH = path.join(STORAGE_DIR, "wallet-addresses.json");
+
+function ensure() {
+  if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR, { recursive: true });
+  if (!fs.existsSync(ADDR_PATH)) fs.writeFileSync(ADDR_PATH, "{}");
+}
+function readAll() { ensure(); return JSON.parse(fs.readFileSync(ADDR_PATH, "utf8")); }
+function writeAll(m) { fs.writeFileSync(ADDR_PATH, JSON.stringify(m, null, 2)); }
+
+async function getAddress(userId) { const m = readAll(); return m[userId] || null; }
+async function setAddress(userId, address) { const m = readAll(); m[userId] = address; writeAll(m); return true; }
+
+module.exports = { getAddress, setAddress };

+ 135 - 1
src/client/assets/styles/style.css

@@ -1746,7 +1746,6 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .mode-buttons-row {
   display: flex;
   flex-direction: row;
-  gap: 2em;
   align-items: flex-start;
   margin-bottom: 1.5em;
 }
@@ -2116,3 +2115,138 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
   font-weight: bold;
   margin-bottom: 8px;
 }
+
+/*projects*/
+.project-actions {
+    display: flex;
+    justify-content: flex-start;
+    align-items: center;
+    gap: 10px; 
+}
+
+.project-card.status-completed .badge{ background:rgba(59,130,246,.12); color:#93c5fd; border-color:rgba(59,130,246,.25); }
+
+.badge{
+  position:absolute;
+  top:14px; right:14px;
+  font-size:.75rem;
+  letter-spacing:.02em;
+  padding:6px 8px;
+  border-radius:8px;
+  border:1px solid var(--line);
+  color:var(--muted);
+}
+
+.milestones, .bounties{ display:flex; flex-direction:column; gap:8px; }
+.milestone-item, .bounty-item{
+  padding:12px;
+  border:1px solid var(--line);
+  border-radius:12px;
+  background:#121620;
+}
+
+.milestone-row{
+  display:flex; gap:10px; flex-wrap:wrap; align-items:center; justify-content:space-between;
+  margin-bottom:6px;
+}
+
+.milestone-title{ font-weight:600; }
+.milestone-percent{ padding:4px 8px; border-radius:8px; border:1px solid var(--line); font-size:.8rem; color:#b7c2d6; }
+.milestone-due{ color:#c9a86a; font-size:.85rem; }
+
+.bounty-main{
+  display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:6px;
+}
+
+.bounty-title{ font-weight:600; }
+.bounty-amount{
+  font-variant-numeric: tabular-nums;
+  padding:4px 8px; border-radius:8px; background:#18202f; border:1px solid var(--line);
+}
+
+.pledge-box{
+  margin-top:6px;
+  padding:14px;
+  border:1px solid var(--line);
+  border-radius:12px;
+  background:#121620;
+  display:grid;
+  gap:10px;
+}
+
+.pledge-box form{ display:flex; gap:10px; flex-wrap:wrap; }
+.pledge-box input[type="number"]{
+  background:#0f1420; border:1px solid #2b3240; color:var(--text);
+  padding:10px 12px; border-radius:10px; outline:none;
+}
+
+.pledge-box input[type="number"]:focus{ border-color:#3a475c; }
+
+@media (max-width: 420px){
+  .card-field{ grid-template-columns: 1fr; }
+  .milestone-row{ flex-direction:column; align-items:flex-start; gap:6px; }
+}
+
+/*banking*/
+.addr-form-card, .addr-list-card {
+  border: 1px solid rgba(255,255,255,0.1);
+  border-radius: 12px;
+  background: rgba(255,255,255,0.03);
+  padding: 20px;
+}
+
+.addr-form-card.wide { 
+  padding: 22px; 
+}
+
+.addr-form .form-row {
+  display: grid;
+  grid-template-columns: 140px 1fr;
+  gap: 10px;
+  align-items: center;
+  margin-bottom: 12px;
+}
+
+.addr-form .form-input {
+  padding: 0 14px;
+}
+
+.addr-form .form-input.xl { 
+  height: 50px;
+  font-size: 16px; 
+}
+
+.addr-form .form-actions {
+  display: flex;
+  margin-top: 8px;
+}
+
+.addr-list-card .form-row {
+  display: grid;
+  grid-template-columns: 150px 1fr;
+  gap: 10px;
+  align-items: center;
+}
+
+.addr-list-card .form-input {
+  padding: 0 14px;
+}
+
+.addr-search input[type="text"]:focus,
+.addr-form .form-input:focus {
+  outline: none;
+  border-color: #ffa300;
+}
+
+.addr-form .form-actions .mini-btn {
+  background: #ffa300;
+  border: none;
+  color: #fff;
+  padding: 9px 14px;
+  border-radius: 8px;
+  cursor: pointer;
+}
+
+.addr-form .form-actions .mini-btn:hover {
+  background: #ff7f00;
+}

+ 289 - 14
src/client/assets/translations/oasis_en.js

@@ -178,12 +178,12 @@ module.exports = {
     ],
     publishCustom: "Write advanced post",
     subtopicLabel: "Create a subtopic of this post",
-    // mentions
+    //mentions
     messagePreview: "Post Preview",
     mentionsMatching: "Matching Mentions",
     mentionsName: "Name",
     mentionsRelationship: "Relationship",
-    // settings
+    //settings
     updateit: "GET UPDATES!",
     info: "Info",
     settingsIntro: ({ version }) => [
@@ -205,6 +205,9 @@ module.exports = {
     exportDataTitle: "Backup",
     exportDataDescription: "Download your data (secret key excluded!)",
     exportDataButton: "Download database",
+    pubWallet: "PUB Wallet",
+    pubWalletDescription: "Set the PUB wallet URL. This will be used for PUB transactions (including the UBI).",
+    pubWalletConfiguration: "Save Configuration",
     importTitle: "Import data",
     importDescription: "Import your encrypted secret (private key) to enable your avatar",
     importAttach: "Attach encrypted file (.enc)",    
@@ -1033,6 +1036,7 @@ module.exports = {
     typeAudio:            "AUDIO",
     typeMarket:           "MARKET",
     typeJob:              "JOB",
+    typeProject:          "PROJECT",
     typeVideo:            "VIDEO",
     typeVote:             "SPREAD",
     typeEvent:            "EVENT",
@@ -1045,6 +1049,7 @@ module.exports = {
     typeContact:          "CONTACT",
     typePub:              "PUB",
     typeTombstone:	  "TOMBSTONE",
+    typeBanking:          "BANKING",
     activitySupport:      "New alliance forged",
     activityJoin:         "New PUB joined",
     question:             "Question",
@@ -1066,6 +1071,12 @@ module.exports = {
     location:             "Location",
     contentWarning:       "Subject",
     personName:           "Inhabitant Name",
+    typeBankWallet:       "BANKING/WALLET",
+    typeBankClaim:        "BANKING/UBI",
+    bankWalletConnected:  "ECOin Wallet",
+    bankUbiReceived:      "UBI Received",
+    bankTx:               "Tx",
+    bankEpochShort:       "Epoch",
     //reports
     reportsTitle: "Reports",
     reportsDescription: "Manage and track reports related to issues, bugs, abuses and content warnings in your network.",
@@ -1202,6 +1213,7 @@ module.exports = {
     agendaFilterReports: "REPORTS",
     agendaFilterTransfers: "TRANSFERS",
     agendaFilterJobs: "JOBS",
+    agendaFilterProjects: "PROJECTS",
     agendaNoItems: "No assignments found.",
     agendaAuthor: "By",
     agendaDiscardButton: "Discard",
@@ -1276,18 +1288,44 @@ module.exports = {
     voteInspiring: "Inspiring",
     voteSpam: "Spam",
     //inbox
-    publishBlog: "Publish Blog Post",
-    privateMessage:       "PM",
-    pmSendTitle:          "Private Messages",
-    pmSend:               "Send!",
-    pmDescription:        "Use this form to send an encrypted message to other inhabitants.",
-    pmRecipients:         "Recipients",
-    pmRecipientsHint:     "Enter Oasis IDs separated by commas",
-    pmSubject:            "Subject",
-    pmSubjectHint:        "Enter the subject of the message",
-    pmText:               "Message",
-    pmFile:               "Attached file",
-    //blockchain visualization
+    publishBlog: "Publish Blog",
+    privateMessage: "PM",
+    pmSendTitle: "Private Messages",
+    pmSend: "Send!",
+    pmDescription: "Use this form to send an encrypted message to other inhabitants.",
+    pmRecipients: "Recipients",
+    pmRecipientsHint: "Enter Oasis IDs separated by commas",
+    pmSubject: "Subject",
+    pmSubjectHint: "Enter the message subject",
+    pmText: "Message",
+    pmFile: "Attachment",
+    private: "Private",
+    privateDescription: "Your encrypted messages.",
+    privateInbox: "Inbox",
+    privateSent: "Sent",
+    privateDelete: "Delete",
+    pmCreateButton: "Write a PM",
+    noPrivateMessages: "No private messages.",
+    pmFromLabel: "From:",
+    pmToLabel: "To:",
+    pmInvalidMessage: "Invalid message",
+    pmNoSubject: "(no subject)",
+    pmBotJobs: "42-JobsBOT",
+    pmBotProjects: "42-ProjectsBOT",
+    pmBotMarket: "42-MarketBOT",
+    inboxJobSubscribedTitle: "New subscription to your job offer",
+    pmInhabitantWithId: "Inhabitant with OASIS ID:",
+    pmHasSubscribedToYourJobOffer: "has subscribed to your job offer",
+    inboxProjectCreatedTitle: "New project created",
+    pmHasCreatedAProject: "has created a project",
+    inboxMarketItemSoldTitle: "Item Sold",
+    pmYourItem: "Your item",
+    pmHasBeenSoldTo: "has been sold to",
+    pmFor: "for",
+    inboxProjectPledgedTitle: "New pledge to your project",
+    pmHasPledged: "has pledged",
+    pmToYourProject: "to your project",
+    //blockexplorer
     blockchain: 'BlockExplorer',
     blockchainTitle: 'BlockExplorer',
     blockchainDescription: 'Explore and visualize the blocks in the blockchain.',
@@ -1306,6 +1344,70 @@ module.exports = {
     blockchainBack: 'Back to Blockexplorer',
     blockchainContentDeleted: "This content has been tombstoned",
     visitContent: "Visit Content",
+    //banking
+    banking: 'Banking',
+    bankingTitle: 'Banking',
+    bankingDescription: 'Universal Basic Income for Oasis inhabitants, distributed per epoch based on participation and trust.',
+    bankOverview: 'Overview',
+    bankEpochs: 'Epochs',
+    bankRules: 'Rules',
+    pending: 'Pending',
+    closed: 'Closed',
+    bankBack: 'Back to Banking',
+    bankViewTx: 'View Tx',
+    bankClaimNow: 'Claim now',
+    bankPubBalance: 'PUB Balance',
+    bankEpoch: 'Epoch',
+    bankPool: 'Pool (this epoch)',
+    bankWeightsSum: 'Sum of weights',
+    bankAllocations: 'Allocations',
+    bankNoAllocations: 'No allocations found.',
+    bankNoEpochs: 'No epochs found.',
+    bankEpochAllocations: 'Epoch allocations',
+    bankAllocId: 'Allocation ID',
+    bankAllocDate: 'Date',
+    bankAllocConcept: 'Concept',
+    bankAllocFrom: 'From',
+    bankAllocTo: 'To',
+    bankAllocAmount: 'Amount',
+    bankAllocStatus: 'Status',
+    bankEpochId: 'Epoch ID',
+    bankRuleHash: 'Rules Snapshot Hash',
+    bankViewEpoch: 'View Epoch',
+    bankUserBalance: 'Your Balance',
+    ecoWalletNotConfigured: 'ECOin Wallet not configured',
+    editWallet: 'Edit wallet',
+    addWallet: 'Add wallet',
+    bankAddresses: 'Addresses',
+    bankNoAddresses: 'No addresses found.',
+    bankUser: 'Oasis ID',
+    bankAddress: 'Address',
+    bankAddAddressTitle: 'Add ECOIN address',
+    bankAddAddressUser: 'Oasis ID',
+    bankAddAddressAddress: 'ECOIN Address',
+    bankAddAddressSave: 'Save',
+    bankAddressAdded: 'Address added',
+    bankAddressUpdated: 'Address updated',
+    bankAddressExists: 'Address already exists',
+    bankAddressInvalid: 'Invalid address',
+    bankAddressDeleted: 'Address deleted',
+    bankAddressNotFound: 'Address not found',
+    bankAddressTotal: 'Total Addresses',
+    bankAddressSearch: 'Search @inhabitant or address',
+    bankAddressActions: 'Actions',
+    bankAddressDelete: 'Delete',
+    bankAddressSource: 'Source',
+    bankAddressDeleteConfirm: 'Delete this address?',
+    search: 'Search!',
+    bankLocal: 'Local',
+    bankFromOasis: 'Oasis',
+    bankCopy: 'Copy',
+    bankCopied: 'Copied',
+    bankMyAddress: 'Your address',
+    bankRemoveMyAddress: 'Remove my address',
+    bankNotRemovableOasis: 'Addresses cannot be removed locally',
+    bankingUserEngagementScore: "Engagement Score",
+    bankingFutureUBI: "Estimated UBI Allocation",
     //stats
     statsTitle: 'Statistics',
     statistics: "Statistics",
@@ -1326,11 +1428,17 @@ module.exports = {
     statsNetworkContent: "Content",
     statsYourMarket: "Market",
     statsYourJob: "Jobs",
+    statsYourProject: "Projects",
     statsYourTransfer: "Transfers",
     statsYourForum: "Forums",   
     statsNetworkOpinions: "Opinions",
     statsDiscoveredMarket: "Market",
     statsDiscoveredJob: "Jobs",
+    statsDiscoveredProject: "Projects",
+    statsBankingTitle: "Banking",
+    statsEcoWalletLabel: "ECOIN Wallet",
+    statsEcoWalletNotConfigured:  "Not configured!",
+    statsTotalEcoAddresses: "Total addresses",
     statsDiscoveredTransfer: "Transfers",
     statsDiscoveredForum: "Forums",
     statsNetworkTombstone: "Tombstones",
@@ -1341,6 +1449,7 @@ module.exports = {
     statsMarket: "Market",
     statsForum: "Forums",
     statsJob: "Jobs",
+    statsProject: "Projects",
     statsReport: "Reports",
     statsFeed: "Feeds",
     statsTribe: "Tribes",
@@ -1349,11 +1458,51 @@ module.exports = {
     statsVideo: "Videos",
     statsDocument: "Documents",
     statsTransfer: "Transfers",
+    statsAiExchange: "AI",
     statsPost: "Posts",
     statsOasisID: "Oasis ID",
     statsSize: "Total (size)",
     statsBlockchainSize: "Blockchain (size)",
     statsBlobsSize: "Blobs (size)",
+    statsActivity7d: "Activity (last 7 days)",
+    statsActivity7dTotal: "7-day total",
+    statsActivity30dTotal: "30-day total",
+    day: "Day",
+    messages: "Messages",
+    statsProject: "Projects",
+    statsProjectsTitle: "Projects",
+    statsProjectsTotal: "Total projects",
+    statsProjectsActive: "Active",
+    statsProjectsCompleted: "Completed",
+    statsProjectsPaused: "Paused",
+    statsProjectsCancelled: "Cancelled",
+    statsProjectsGoalTotal: "Total goal",
+    statsProjectsPledgedTotal: "Total pledged",
+    statsProjectsSuccessRate: "Success rate",
+    statsProjectsAvgProgress: "Average progress",
+    statsProjectsMedianProgress: "Median progress",
+    statsProjectsActiveFundingAvg: "Avg. active funding",
+    statsJobsTitle: "Jobs",
+    statsJobsTotal: "Total jobs",
+    statsJobsOpen: "Open",
+    statsJobsClosed: "Closed",
+    statsJobsOpenVacants: "Open vacants",
+    statsJobsSubscribersTotal: "Total subscribers",
+    statsJobsAvgSalary: "Average salary",
+    statsJobsMedianSalary: "Median salary",
+    statsMarketTitle: "Market",
+    statsMarketTotal: "Total items",
+    statsMarketForSale: "For sale",
+    statsMarketReserved: "Reserved",
+    statsMarketClosed: "Closed",
+    statsMarketSold: "Sold",
+    statsMarketRevenue: "Revenue",
+    statsMarketAvgSoldPrice: "Avg. sold price",
+    statsUsersTitle: "Inhabitants",
+    user: "Inhabitant",
+    statsTombstoneTitle: "Tombstones",
+    statsNetworkTombstones: "Network tombstones",
+    statsTombstoneRatio: "Tombstone ratio (%)",
     //AI
     ai: "AI",
     aiTitle: "AI",
@@ -1367,6 +1516,23 @@ module.exports = {
     aiConfiguration: "Set prompt",
     aiPromptUsed: "Prompt",
     aiClearHistory: "Clear chat history",
+    aiSharePrompt: "Add this answer to collective training?",
+    aiShareYes: "Yes",
+    aiShareNo: "No",
+    aiSharedLabel: "Added to training",
+    aiRejectedLabel: "Not added to training",
+    aiServerError: "The AI could not answer. Please try again.",
+    aiInputPlaceholder: "What is Oasis?",
+    typeAiExchange: "AI",
+    aiApproveTrain: "Add to collective training",
+    aiRejectTrain: "Do not train",
+    aiTrainPending: "Pending approval",
+    aiTrainApproved: "Approved for training",
+    aiTrainRejected: "Rejected for training",
+    aiSnippetsUsed: "Context lines used",
+    aiSnippetsLearned: "Snippets learned",
+    statsAITraining: "AI training",
+    statsAIExchanges: "Model Exchanges",
     //market
     marketMineSectionTitle: "Your Items",
     marketCreateSectionTitle: "Create Item",
@@ -1480,6 +1646,111 @@ module.exports = {
     jobTimeComplete: "Full-time",
     jobsDeleteButton: "DELETE",
     jobsUpdateButton: "UPDATE",
+    //projects
+    projectsTitle: "Projects",
+    projectsDescription: "Create, fund, and follow community-driven projects in your network.",
+    projectCreateProject: "Create Project",
+    projectCreateButton: "Create Project",
+    projectUpdateButton: "UPDATE",
+    projectDeleteButton: "DELETE",
+    projectNoProjectsFound: "No projects found.",
+    projectFilterAll: "ALL",
+    projectFilterMine: "MINE",
+    projectFilterActive: "ACTIVE",
+    projectFilterPaused: "PAUSED",
+    projectFilterCompleted: "COMPLETED",
+    projectFilterFollowing: "FOLLOWING",
+    projectFilterRecent: "RECENT",
+    projectFilterTop: "TOP",
+    projectAllTitle: "Projects",
+    projectMineTitle: "Your Projects",
+    projectActiveTitle: "Active Projects",
+    projectPausedTitle: "Paused Projects",
+    projectCompletedTitle: "Completed Projects",
+    projectFollowingTitle: "Following Projects",
+    projectRecentTitle: "Recent Projects",
+    projectTopTitle: "Top Funded",
+    projectTitlePlaceholder: "Project name",
+    projectImage: "Upload Image (jpeg, jpg, png, gif) (max-size: 500px x 400px)",
+    projectDescription: "Description",
+    projectDescriptionPlaceholder: "Tell the story and goals…",
+    projectGoal: "Goal (ECO)",
+    projectGoalPlaceholder: "50000",
+    projectDeadline: "Deadline",
+    projectProgress: "Starting Progress (%)",
+    projectStatus: "Status",
+    projectFunding: "Funding",
+    projectPledged: "Pledged",
+    projectSetStatus: "Set Status",
+    projectSetProgress: "Update Progress",
+    projectFollowButton: "FOLLOW",
+    projectUnfollowButton: "UNFOLLOW",
+    projectStatusACTIVE: "ACTIVE",
+    projectStatusPAUSED: "PAUSED",
+    projectStatusCOMPLETED: "COMPLETED",
+    projectStatusCANCELLED: "CANCELLED",
+    projectPledgeTitle: "Back this project",
+    projectPledgePlaceholder: "Amount in ECO",
+    projectPledgeButton: "Pledge",
+    projectBounties: "Bounties",
+    projectBountiesInputLabel: "Bounties (one per line: Title|Amount [ECO]|Description)",
+    projectBountiesPlaceholder: "Fix UI bug|100|Link to issue\nWrite docs|250|Outline usage examples",
+    projectNoBounties: "No bounties found.",
+    projectTitle: "Title",
+    projectAddBountyTitle: "New Bounty",
+    projectBountyTitle: "Bounty Title",
+    projectBountyAmount: "Amount (ECO)",
+    projectBountyDescription: "Description",
+    projectMilestoneSelect: "Select Milestone",
+    projectBountyCreateButton: "Create Bounty",
+    projectBountyStatus: "Bounty Status",
+    projectBountyOpen: "open",
+    projectBountyClaimed: "claimed",
+    projectBountyDone: "completed",
+    projectBountyClaimedBy: "claimed by",
+    projectBountyClaimButton: "claim",
+    projectBountyCompleteButton: "Mark Completed",
+    projectMilestones: "Milestones",
+    projectAddMilestoneTitle: "New milestone",
+    projectMilestoneTitle: "Milestone Title",
+    projectMilestoneTargetPercent: "Percent (%)",
+    projectMilestoneDueDate: "Date",
+    projectMilestoneCreateButton: "Create Milestone",
+    projectMilestoneStatus: "Milestone Status",
+    projectMilestoneOpen: "open",
+    projectMilestoneDone: "completed",
+    projectMilestoneMarkDone: "Mark as Completed",
+    projectMilestoneDue: "due",
+    projectNoMilestones: "No milestones found.",
+    projectMilestoneMarkDone: "Mark as Done",
+    projectMilestoneTitlePlaceholder: "Enter milestone title",
+    projectMilestoneDescriptionPlaceholder: "Enter description for this milestone",
+    projectMilestoneDescription: "Milestone Description",
+    projectBudgetGoal: "Budget (Goal)",
+    projectBudgetAssigned: "Assigned to bounties",
+    projectBudgetRemaining: "Remaining",
+    projectBudgetOver: "⚠ Over budget: assigned exceeds goal",
+    projectFollowers: "Followers",
+    projectFollowersTitle: "Followers",
+    projectFollowersNone: "No followers yet.",
+    projectMore: "more",
+    projectYouFollowHint: "You follow this project",
+    projectBackers: "Backers",
+    projectBackersTitle: "Backers",
+    projectBackersTotal: "Total backers",
+    projectBackersTotalPledged: "Total pledged",
+    projectBackersYourPledge: "Your pledge",
+    projectBackersNone: "No pledges yet.",
+    projectNoRemainingBudget: "No remaining budget.",
+    projectFilterBackers: "BACKERS",
+    projectBackersLeaderboardTitle: "Top Backers",
+    projectNoBackersFound: "No backers found.",
+    projectBackerAmount: "Total contributed",
+    projectBackerPledges: "Pledges",
+    projectBackerProjects: "Projects",
+    projectPledgeAmount: "Amount",
+    projectSelectMilestoneOrBounty: "Select Milestone or Bounty",
+    projectPledgeButton: "Pledge",
     //modules
     modulesModuleName: "Name",
     modulesModuleDescription: "Description",
@@ -1549,6 +1820,10 @@ module.exports = {
     modulesForumDescription: "Module to discover and manage forums.",
     modulesJobsLabel: "Jobs",
     modulesJobsDescription: "Module to discover and manage jobs.",
+    modulesProjectsLabel: "Projects",
+    modulesProjectsDescription: "Module to explore, crowd-funding and manage projects.",
+    modulesBankingLabel: "Banking",
+    modulesBankingDescription: "Module to distribute a fair Universal Basic Income (UBI) using commons-treasury."
      
      //END
     }

+ 344 - 55
src/client/assets/translations/oasis_es.js

@@ -205,6 +205,9 @@ module.exports = {
     exportDataTitle: "Backup",
     exportDataDescription: "Descargar tu blockchain (llave privada excluida!)",
     exportDataButton: "Descargar blockchain",
+    pubWallet: "Cartera del PUB",
+    pubWalletDescription: "Establece la URL del wallet del PUB. Esta se utilizará para las transacciones del PUB (incluída la RBU).",
+    pubWalletConfiguration: "Guardar Configuración",
     importTitle: "Importar datos",
     importDescription: "Importa tu secreto cifrado (llave privada) para activar tu avatar",
     importAttach: "Adjuntar fichero cifrado (.enc)",    
@@ -999,57 +1002,60 @@ module.exports = {
     alreadyRefeeded: "Ya has hecho refeed de esto.",
     //activity
     activityTitle:        "Actividad",
-    yourActivity:         "Tu Actividad",
-    globalActivity:       "Actividad Global",
+    yourActivity:         "Tu actividad",
+    globalActivity:       "Actividad global",
     activityList:         "Actividad",
-    activityDesc:         "Ver la actividad más reciente en tu red.",
+    activityDesc:         "Consulta la actividad reciente de tu red.",
     allButton:            "TODOS",
-    mineButton:           "MIAS",
+    mineButton:           "MÍO",
     noActions:            "No hay actividad disponible.",
-    performed:            "→",   
-    from: "De",
-    to: "A",
-    amount: "Cantidad",
-    concept: "Concepto",
-    description: "Descripción",
-    meme: "Meme",
-    activityContact: "Contacto",
-    activityBy: "Nombre",
-    activityPixelia: "Nuevo píxel agregado",
+    performed:            "→",
+    from:                 "De",
+    to:                   "Para",
+    amount:               "Cantidad",
+    concept:              "Concepto",
+    description:          "Descripción",
+    meme:                 "Meme",
+    activityContact:      "Contacto",
+    activityBy:           "Nombre",
+    activityPixelia:      "Nuevo píxel añadido",
     viewImage:            "Ver imagen",
     playAudio:            "Reproducir audio",
-    playVideo:            "Reproducir video",
-    typeRecent:           "RECIENTES",
+    playVideo:            "Reproducir vídeo",
+    typeRecent:           "RECIENTE",
     errorActivity:        "Error al recuperar la actividad",
     typePost:             "PUBLICACIÓN",
-    typeTribe:            "TRIBUS",
+    typeTribe:            "TRIBU",
     typeAbout:            "HABITANTE",
     typeCurriculum:       "CV",
-    typeImage:            "IMÁGEN",
+    typeImage:            "IMAGEN",
     typeBookmark:         "MARCADOR",
     typeDocument:         "DOCUMENTO",
-    typeVotes:            "GOBIERNO",
+    typeVotes:            "GOBERNANZA",
     typeAudio:            "AUDIO",
     typeMarket:           "MERCADO",
     typeJob:              "TRABAJO",
-    typeVideo:            "VIDEO",
-    typeVote:             "PROPAGACIÓN",
+    typeProject:          "PROYECTO",
+    typeVideo:            "VÍDEO",
+    typeVote:             "DIFUSIÓN",
     typeEvent:            "EVENTO",
     typeTransfer:         "TRANSFERENCIA",
-    typeTask:             "TAREA",
+    typeTask:             "TAREAS",
     typePixelia:          "PIXELIA",
-    typeForum: 	          "FORO",
-    typeReport:           "REPORT",
+    typeForum:            "FORO",
+    typeReport:           "REPORTE",
     typeFeed:             "FEED",
     typeContact:          "CONTACTO",
     typePub:              "PUB",
+    typeTombstone:        "TOMBSTONE",
+    typeBanking:          "BANCA",
     activitySupport:      "Nueva alianza forjada",
-    activityJoin:         "Nueva habitante en PUB",
+    activityJoin:         "Nuevo PUB unido",
     question:             "Pregunta",
-    deadline:             "Plazo",
+    deadline:             "Fecha límite",
     status:               "Estado",
     votes:                "Votos",
-    totalVotes:           "Total de Votos",
+    totalVotes:           "Votos totales",
     name:                 "Nombre",
     skills:               "Habilidades",
     tags:                 "Etiquetas",
@@ -1060,10 +1066,15 @@ module.exports = {
     activitySpread:       "->",
     visitLink:            "Visitar enlace",
     viewDocument:         "Ver documento",
-    description:          "Descripción",
     location:             "Ubicación",
     contentWarning:       "Asunto",
-    personName:           "Nombre de Habitante",
+    personName:           "Nombre del habitante",
+    typeBankWallet:       "BANCA/CARTERA",
+    typeBankClaim:        "BANCA/UBI",
+    bankWalletConnected:  "Cartera ECOin",
+    bankUbiReceived:      "UBI recibida",
+    bankTx:               "Tx",
+    bankEpochShort:       "Época",
     //reports
     reportsTitle: "Informes",
     reportsDescription: "Gestiona y realiza un seguimiento de los informes relacionados con problemas, errores, abusos y advertencias de contenido en tu red.",
@@ -1200,6 +1211,7 @@ module.exports = {
     agendaFilterReports: "INFORMES",
     agendaFilterTransfers: "TRANSFERENCIAS",
     agendaFilterJobs: "TRABAJOS",
+    agendaFilterProjects: "PROYECTOS",
     agendaNoItems: "No se encontraron asignaciones.",
     agendaDiscardButton: "Descartar",
     agendaRestoreButton: "Restaurar",
@@ -1275,35 +1287,126 @@ module.exports = {
     voteSpam: "Spam",
     //inbox
     publishBlog: "Publicar Blog",
-    privateMessage:       "MP",
-    pmSendTitle:          "Mensajes Privados",
-    pmSend:               "Enviar!",
-    pmDescription:        "Usa este formulario para enviar un mensaje encriptado a otros habitantes.",
-    pmRecipients:         "Destinatarios",
-    pmRecipientsHint:     "Introduce los IDs de Oasis separados por comas",
-    pmSubject:            "Asunto",
-    pmSubjectHint:        "Introduce el asunto del mensaje",
-    pmText:               "Mensaje",
-    pmFile:               "Archivo adjunto",
-    //blockchain visor
-    blockchain: "BlockExplorer",
-    blockchainTitle: 'BlockExplorer',
-    blockchainDescription: 'Explora y visualiza los bloques en la blockchain.',
-    blockchainNoBlocks: 'No se encontraron bloques en la blockchain.',
-    blockchainBlockID: 'ID del bloque',
+    privateMessage: "MP",
+    pmSendTitle: "Mensajes Privados",
+    pmSend: "Enviar!",
+    pmDescription: "Usa este formulario para enviar un mensaje cifrado a otros habitantes.",
+    pmRecipients: "Destinatarios",
+    pmRecipientsHint: "Introduce los IDs de Oasis separados por comas",
+    pmSubject: "Asunto",
+    pmSubjectHint: "Introduce el asunto del mensaje",
+    pmText: "Mensaje",
+    pmFile: "Archivo adjunto",
+    private: "Privados",
+    privateDescription: "Tus mensajes cifrados.",
+    privateInbox: "Bandeja",
+    privateSent: "Enviados",
+    privateDelete: "Borrar",
+    pmCreateButton: "Escribir MP",
+    noPrivateMessages: "No hay mensajes privados.",
+    performed: "realizado",
+    pmFromLabel: "De:",
+    pmToLabel: "Para:",
+    pmInvalidMessage: "Mensaje no válido",
+    pmNoSubject: "(sin asunto)",
+    pmBotJobs: "42-JobsBOT",
+    pmBotProjects: "42-ProjectsBOT",
+    pmBotMarket: "42-MarketBOT",
+    inboxJobSubscribedTitle: "Nueva suscripción a tu oferta de trabajo",
+    pmInhabitantWithId: "Habitante con ID OASIS:",
+    pmHasSubscribedToYourJobOffer: "se ha suscrito a tu oferta de trabajo",
+    inboxProjectCreatedTitle: "Nuevo proyecto creado",
+    pmHasCreatedAProject: "ha creado un proyecto",
+    inboxMarketItemSoldTitle: "Artículo vendido",
+    pmYourItem: "Tu artículo",
+    pmHasBeenSoldTo: "se ha vendido a",
+    pmFor: "por",
+    inboxProjectPledgedTitle: "Nueva aportación a tu proyecto",
+    pmHasPledged: "ha aportado",
+    pmToYourProject: "a tu proyecto",
+    //blockexplorer
+    blockchain: 'Explorador de Bloques',
+    blockchainTitle: 'Explorador de Bloques',
+    blockchainDescription: 'Explora y visualiza los bloques de la cadena.',
+    blockchainNoBlocks: 'No se encontraron bloques en la cadena.',
+    blockchainBlockID: 'ID del Bloque',
     blockchainBlockAuthor: 'Autoría',
     blockchainBlockType: 'Tipo',
-    blockchainBlockTimestamp: 'Fecha y hora',
+    blockchainBlockTimestamp: 'Marca de tiempo',
     blockchainBlockContent: 'Bloque',
     blockchainBlockURL: 'URL:',
     blockchainContent: 'Bloque',
     blockchainContentPreview: 'Vista previa del contenido del bloque',
     blockchainDetails: 'Ver detalles del bloque',
-    blockchainBlockInfo: 'Información del bloque',
-    blockchainBlockDetails: 'Detalles del bloque seleccionado', 
-    blockchainBack: 'Volver al explorador de bloques',
-    blockchainContentDeleted: "Este contenido ha sido eliminado",
-    visitContent: 'Visitar Contenido',
+    blockchainBlockInfo: 'Información del Bloque',
+    blockchainBlockDetails: 'Detalles del bloque seleccionado',
+    blockchainBack: 'Volver al Blockexplorer',
+    blockchainContentDeleted: 'Este contenido ha sido eliminado.',
+    visitContent: 'Visitar contenido',
+    // banking
+    banking: 'Banking',
+    bankingTitle: 'Banking',
+    bankingDescription: 'Renta Básica Universal para habitantes de Oasis, distribuida por épocas según participación y confianza.',
+    bankOverview: 'Resumen',
+    bankEpochs: 'Épocas',
+    bankRules: 'Reglas',
+    pending: 'Pendientes',
+    closed: 'Cerradas',
+    bankBack: 'Volver a Banking',
+    bankViewTx: 'Ver Tx',
+    bankClaimNow: 'Reclamar',
+    bankPubBalance: 'Saldo del PUB',
+    bankEpoch: 'Época',
+    bankPool: 'Fondo (esta época)',
+    bankWeightsSum: 'Suma de pesos',
+    bankAllocations: 'Asignaciones',
+    bankNoAllocations: 'No se encontraron asignaciones.',
+    bankNoEpochs: 'No se encontraron épocas.',
+    bankEpochAllocations: 'Asignaciones de la época',
+    bankAllocId: 'ID de asignación',
+    bankAllocDate: 'Fecha',
+    bankAllocConcept: 'Concepto',
+    bankAllocFrom: 'De',
+    bankAllocTo: 'Para',
+    bankAllocAmount: 'Cantidad',
+    bankAllocStatus: 'Estado',
+    bankEpochId: 'ID de época',
+    bankRuleHash: 'Hash del snapshot de reglas',
+    bankViewEpoch: 'Ver época',
+    bankUserBalance: 'Tu saldo',
+    ecoWalletNotConfigured: 'Cartera de ECOin no configurada',
+    editWallet: 'Editar cartera',
+    addWallet: 'Añadir cartera',
+    bankAddresses: 'Direcciones',
+    bankNoAddresses: 'No se encontraron direcciones.',
+    bankUser: 'ID de Oasis',
+    bankAddress: 'Dirección',
+    bankAddAddressTitle: 'Añadir dirección de ECOIN',
+    bankAddAddressUser: 'ID de Oasis',
+    bankAddAddressAddress: 'Dirección de ECOIN',
+    bankAddAddressSave: 'Guardar',
+    bankAddressAdded: 'Dirección añadida',
+    bankAddressUpdated: 'Dirección actualizada',
+    bankAddressExists: 'La dirección ya existe',
+    bankAddressInvalid: 'Dirección no válida',
+    bankAddressDeleted: 'Dirección eliminada',
+    bankAddressNotFound: 'Dirección no encontrada',
+    bankAddressTotal: 'Total de Direcciones',
+    bankAddressSearch: 'Buscar @habitante o dirección',
+    bankAddressActions: 'Acciones',
+    bankAddressDelete: 'Eliminar',
+    bankAddressSource: 'Fuente',
+    bankAddressDeleteConfirm: 'Eliminar esta dirección?',
+    search: 'Buscar!',
+    bankLocal: 'Local',
+    bankFromOasis: 'Oasis',
+    bankCopy: 'Copiar',
+    bankCopied: 'Copiado',
+    bankMyAddress: 'Tu dirección',
+    bankRemoveMyAddress: 'Eliminar mi dirección',
+    bankNotRemovableOasis: 'Las direcciones no se pueden eliminar localmente',
+    bankingUserEngagementScore: "Puntuación de Compromiso",
+    bankingFutureUBI: "Asignación Estimada de UBI",
     //stats
     statsTitle: 'Estadísticas',
     statistics: "Estadísticas",
@@ -1318,14 +1421,34 @@ module.exports = {
     statsYourContent: "Contenido",
     statsYourOpinions: "Opiniones",
     statsYourTombstone: "Lápidas",
+    statsYourProject: "Proyectos",
+    statsDiscoveredProject: "Proyectos",
+    statsBankingTitle: "Banca",
+    statsEcoWalletLabel: "Cartera de ECOIN",
+    statsEcoWalletNotConfigured: "Sin configurar!",
+    statsTotalEcoAddresses: "Direcciones totales",
+    statsProject: "Proyectos",
     statsNetwork: "Red",
     statsTotalInhabitants: "Habitantes",
     statsDiscoveredTribes: "Tribus",
     statsNetworkContent: "Contenido",
     statsYourMarket: "Mercado",
     statsYourJob: "Trabajos",
-    statsYourTransfer:     "Transferencias",
-    statsYourForum:        "Foros",   
+    statsYourTransfer: "Transferencias",
+    statsYourForum: "Foros",   
+    statsProject: "Proyectos",
+    statsProjectsTitle: "Proyectos",
+    statsProjectsTotal: "Total de proyectos",
+    statsProjectsActive: "Activos",
+    statsProjectsCompleted: "Completados",
+    statsProjectsPaused: "Pausados",
+    statsProjectsCancelled: "Cancelados",
+    statsProjectsGoalTotal: "Meta total",
+    statsProjectsPledgedTotal: "Total comprometido",
+    statsProjectsSuccessRate: "Tasa de éxito",
+    statsProjectsAvgProgress: "Progreso promedio",
+    statsProjectsMedianProgress: "Progreso medio",
+    statsProjectsActiveFundingAvg: "Promedio de financiación activa",
     statsNetworkOpinions: "Opiniones",
     statsDiscoveredMarket: "Mercado",
     statsDiscoveredJob: "Trabajos",
@@ -1347,11 +1470,51 @@ module.exports = {
     statsVideo: "Videos",
     statsDocument: "Documentos",
     statsTransfer: "Transferencias",
+    statsAiExchange: "IA",
     statsPost: "Publicaciones",
     statsOasisID: "ID de Oasis",
     statsSize: "Total (tamaño)",
     statsBlockchainSize: "Blockchain (tamaño)",
-    statsBlobsSize: "Blobs (tamaño)",
+    statsBlobsSize: "Blobs (tamaño)",   
+    statsActivity7d: "Actividad (últimos 7 días)",
+    statsActivity7dTotal: "Total de 7 días",
+    statsActivity30dTotal: "Total de 30 días",
+    day: "Día",
+    messages: "Mensajes",
+    statsProject: "Proyectos",
+    statsProjectsTitle: "Proyectos",
+    statsProjectsTotal: "Total de proyectos",
+    statsProjectsActive: "Activos",
+    statsProjectsCompleted: "Completados",
+    statsProjectsPaused: "Pausados",
+    statsProjectsCancelled: "Cancelados",
+    statsProjectsGoalTotal: "Meta total",
+    statsProjectsPledgedTotal: "Total comprometido",
+    statsProjectsSuccessRate: "Tasa de éxito",
+    statsProjectsAvgProgress: "Progreso promedio",
+    statsProjectsMedianProgress: "Progreso medio",
+    statsProjectsActiveFundingAvg: "Promedio de financiación activa",
+    statsJobsTitle: "Trabajos",
+    statsJobsTotal: "Total de trabajos",
+    statsJobsOpen: "Abiertos",
+    statsJobsClosed: "Cerrados",
+    statsJobsOpenVacants: "Vacantes abiertas",
+    statsJobsSubscribersTotal: "Total de suscriptores",
+    statsJobsAvgSalary: "Salario promedio",
+    statsJobsMedianSalary: "Salario mediano",
+    statsMarketTitle: "Mercado",
+    statsMarketTotal: "Total de artículos",
+    statsMarketForSale: "En venta",
+    statsMarketReserved: "Reservados",
+    statsMarketClosed: "Cerrados",
+    statsMarketSold: "Vendidos",
+    statsMarketRevenue: "Ingresos",
+    statsMarketAvgSoldPrice: "Precio promedio de venta",
+    statsUsersTitle: "Habitantes",
+    user: "Habitante",
+    statsTombstoneTitle: "Tumbas",
+    statsNetworkTombstones: "Tumbas de la red",
+    statsTombstoneRatio: "Ratio de tumbas (%)",
     //AI
     ai: "IA",
     aiTitle: "IA",
@@ -1365,6 +1528,23 @@ module.exports = {
     aiConfiguration: "Configurar prompt",
     aiPromptUsed: "Prompt",
     aiClearHistory: "Borrar historial de chat",
+    aiSharePrompt: "Añadir esta respuesta al entrenamiento colectivo?",
+    aiShareYes: "Sí",
+    aiShareNo: "No",
+    aiSharedLabel: "Añadida al entrenamiento",
+    aiRejectedLabel: "No añadida al entrenamiento",
+    aiServerError: "La IA no ha podido responder. Inténtalo de nuevo.",
+    aiInputPlaceholder: "What is Oasis?",
+    typeAiExchange: "IA",
+    aiApproveTrain: "Añadir al entrenamiento colectivo",
+    aiRejectTrain: "No entrenar",
+    aiTrainPending: "Pendiente de aprobación",
+    aiTrainApproved: "Aprobado para entrenamiento",
+    aiTrainRejected: "Rechazado para entrenamiento",
+    aiSnippetsUsed: "Líneas de contexto usadas",
+    aiSnippetsLearned: "Fragmentos aprendidos",
+    statsAITraining: "Entrenamiento de IA",
+    statsAIExchanges: "Intercambio de Modelos",
     //market
     marketMineSectionTitle: "Tus Artículos",
     marketCreateSectionTitle: "Crear un Artículo",
@@ -1478,6 +1658,111 @@ module.exports = {
     jobTimeComplete: "Completo",
     jobsDeleteButton: "ELIMINAR",
     jobsUpdateButton: "ACTUALIZAR",
+    //projects
+    projectsTitle: "Proyectos",
+    projectsDescription: "Crea, financia y sigue proyectos impulsados por la comunidad en tu red.",
+    projectCreateProject: "Crear Proyecto",
+    projectCreateButton: "Crear Proyecto",
+    projectUpdateButton: "ACTUALIZAR",
+    projectDeleteButton: "ELIMINAR",
+    projectNoProjectsFound: "No se encontraron proyectos.",
+    projectFilterAll: "TODOS",
+    projectFilterMine: "MIS PROYECTOS",
+    projectFilterActive: "ACTIVOS",
+    projectFilterPaused: "PONDERADOS",
+    projectFilterCompleted: "COMPLETADOS",
+    projectFilterFollowing: "SEGUIDOS",
+    projectFilterRecent: "RECIENTES",
+    projectFilterTop: "TOP",
+    projectAllTitle: "Proyectos",
+    projectMineTitle: "Tus Proyectos",
+    projectActiveTitle: "Proyectos Activos",
+    projectPausedTitle: "Proyectos Pausados",
+    projectCompletedTitle: "Proyectos Completados",
+    projectFollowingTitle: "Proyectos Seguidos",
+    projectRecentTitle: "Proyectos Recientes",
+    projectTopTitle: "Mejor Financiados",
+    projectTitlePlaceholder: "Nombre del proyecto",
+    projectImage: "Subir imagen (jpeg, jpg, png, gif) (máx-tamaño: 500px x 400px)",
+    projectDescription: "Descripción",
+    projectDescriptionPlaceholder: "Cuenta la historia y los objetivos…",
+    projectGoal: "Meta (ECO)",
+    projectGoalPlaceholder: "50000",
+    projectDeadline: "Fecha límite",
+    projectProgress: "Progreso inicial (%)",
+    projectStatus: "Estado",
+    projectFunding: "Financiamiento",
+    projectPledged: "Prometido",
+    projectSetStatus: "Establecer estado",
+    projectSetProgress: "Actualizar progreso",
+    projectFollowButton: "SEGUIR",
+    projectUnfollowButton: "DEJAR DE SEGUIR",
+    projectStatusACTIVE: "ACTIVO",
+    projectStatusPAUSED: "PAUSADO",
+    projectStatusCOMPLETED: "COMPLETADO",
+    projectStatusCANCELLED: "CANCELADO",
+    projectPledgeTitle: "Apoya este proyecto",
+    projectPledgePlaceholder: "Cantidad en ECO",
+    projectPledgeButton: "Prometer",
+    projectBounties: "Recompensas",
+    projectBountiesInputLabel: "Recompensas (una por línea: Título|Cantidad [ECO]|Descripción)",
+    projectBountiesPlaceholder: "Arreglar error en UI|100|Enlace al problema\nEscribir documentación|250|Ejemplos de uso",
+    projectNoBounties: "No se encontraron recompensas.",
+    projectTitle: "Título",
+    projectAddBountyTitle: "Añadir nueva recompensa",
+    projectBountyTitle: "Título de la recompensa",
+    projectBountyAmount: "Cantidad (ECO)",
+    projectBountyDescription: "Descripción",
+    projectMilestoneSelect: "Seleccionar hito",
+    projectBountyCreateButton: "Crear recompensa",
+    projectBountyStatus: "Estado de la recompensa",
+    projectBountyOpen: "abierta",
+    projectBountyClaimed: "reclamada",
+    projectBountyDone: "completada",
+    projectBountyClaimedBy: "reclamada por",
+    projectBountyClaimButton: "reclamar",
+    projectBountyCompleteButton: "Marcar como completada",
+    projectMilestones: "Hitos",
+    projectAddMilestoneTitle: "Nuevo hito",
+    projectMilestoneTitle: "Título del hito",
+    projectMilestoneTargetPercent: "Porcentaje (%)",
+    projectMilestoneDueDate: "Fecha",
+    projectMilestoneCreateButton: "Crear hito",
+    projectMilestoneStatus: "Estado del hito",
+    projectMilestoneOpen: "abierto",
+    projectMilestoneDone: "completado",
+    projectMilestoneMarkDone: "Marcar como completado",
+    projectMilestoneDue: "debido",
+    projectNoMilestones: "No se encontraron hitos.",
+    projectMilestoneMarkDone: "Marcar como hecho",
+    projectMilestoneTitlePlaceholder: "Ingresa el título del hito",
+    projectMilestoneDescriptionPlaceholder: "Ingresa la descripción de este hito",
+    projectMilestoneDescription: "Descripción del hito",
+    projectBudgetGoal: "Presupuesto (Objetivo)",
+    projectBudgetAssigned: "Asignado a recompensas",
+    projectBudgetRemaining: "Restante",
+    projectBudgetOver: "⚠ Presupuesto excedido: lo asignado supera el objetivo",
+    projectFollowers: "Seguidores",
+    projectFollowersTitle: "Seguidores",
+    projectFollowersNone: "Aún no hay seguidores.",
+    projectMore: "más",
+    projectYouFollowHint: "Sigues este proyecto",
+    projectBackers: "Patrocinadores",
+    projectBackersTitle: "Patrocinadores",
+    projectBackersTotal: "Total de patrocinadores",
+    projectBackersTotalPledged: "Total aportado",
+    projectBackersYourPledge: "Tu aportación",
+    projectBackersNone: "Aún no hay aportaciones.",
+    projectNoRemainingBudget: "No queda presupuesto.",
+    projectFilterBackers: "MECENAS",
+    projectBackersLeaderboardTitle: "Mecenas destacados",
+    projectNoBackersFound: "No hay mecenas.",
+    projectBackerAmount: "Total aportado",
+    projectBackerPledges: "Aportaciones",
+    projectBackerProjects: "Proyectos",
+    projectPledgeAmount: "Cantidad",
+    projectSelectMilestoneOrBounty: "Seleccionar Hito o Recompensa",
+    projectPledgeButton: "Aportar",
     //modules
     modulesModuleName: "Nombre",
     modulesModuleDescription: "Descripción",
@@ -1499,7 +1784,7 @@ module.exports = {
     modulesMultiverseDescription: "Módulo para recibir contenido de otros pares federados.",
     modulesInvitesLabel: "Invitaciones",
     modulesInvitesDescription: "Módulo para gestionar y aplicar códigos de invitación.",
-    modulesWalletLabel: "Billetera",
+    modulesWalletLabel: "Cartera",
     modulesWalletDescription: "Módulo para gestionar tus activos digitales (ECOin).",
     modulesLegacyLabel: "Legado",
     modulesLegacyDescription: "Módulo para gestionar tu secreto (clave privada) de forma rápida y segura.",
@@ -1547,6 +1832,10 @@ module.exports = {
     modulesForumDescription: "Módulo para descubrir y gestionar foros.",
     modulesJobsLabel: "Trabajos",
     modulesJobsDescription: "Modulo para descubrir y gestionar ofertas de trabajo.",
+    modulesProjectsLabel: "Proyectos",
+    modulesProjectsDescription: "Módulo para explorar, financiar y gestionar proyectos.",
+    modulesBankingLabel: "Banca",
+    modulesBankingDescription: "Módulo para distribuir una Renta Básica Universal (RBU) justa usando la tesorería común.",
      
      //END
     }

+ 373 - 84
src/client/assets/translations/oasis_eu.js

@@ -205,6 +205,9 @@ module.exports = {
     exportDataTitle: "Babeskopia",
     exportDataDescription: "Deskargatu zure datuak (gako sekretua izan ezik!)",
     exportDataButton: "Deskargatu datu-basea",
+    pubWallet: "PUB Wallet",
+    pubWalletDescription: "Ezarri PUB wallet-aren URLa. Hau PUB transakzioetarako erabiliko da (RBU barne).",
+    pubWalletConfiguration: "Ezarpenak gorde",
     importTitle: "Inportatu datuak",
     importDescription: "Inportatu zifratutako sekretua (gako pribatua) zure abatarra gaitzeko",
     importAttach: "Gehitu zifratutako fitxategia (.enc)",    
@@ -999,72 +1002,80 @@ module.exports = {
     refeedButton:     "Berjariotu",
     alreadyRefeeded:  "Berjaiotu duzu jada.",
     //activity
-    activityTitle:   "Jarduera",
-    yourActivity:    "Zeure Jarduera",
-    globalActivity:  "Jarduera Orokorra",
-    activityList:    "Jarduera",
-    activityDesc:    "Ikusi azken jarduera zure sarean.",
-    allButton:       "GUZTIAK",
-    mineButton:      "NEUREAK",
-    noActions:       "Jarduerarik ez.",
-    performed:       "→",
-    from:            "Nork",
-    to:              "Nori",
-    amount:          "Kopurua",
-    concept:         "Kontzeptua",
-    description:     "Deskribapena",
-    meme:            "Memea",
-    activityContact: "Contact",
-    activityBy:      "Izena",
-    activityPixelia: "Pixel berria gehituta",
-    viewImage:       "Ikusi Irudia",
-    playAudio:       "Entzun Audioa",
-    playVideo:       "Ikusi Bideoa",
-    typeRecent:      "BERRIAK",
-    errorActivity:   "Errorea jarduera eskuratzerakoan",
-    typePost:        "BIDALKETAK",
-    typeTribe:       "TRIBUAK",
-    typeAbout:       "BIZILAGUNAK",
-    typeCurriculum:  "CV-ak",
-    typeImage:       "IRUDIAK",
-    typeBookmark:    "MARKAGAILUAK",
-    typeDocument:    "DOKUMENTUAK",
-    typeVotes:       "BOZKAK",
-    typeAudio:       "AUIDOAK",
-    typeMarket:      "MERKATUA",
-    typeJob:         "LANAK",
-    typeVideo:       "BIDEOAK",
-    typeVote:        "ZABALPENAK",
-    typeEvent:       "EKITALDIAK",
-    typeTask:        "ATAZAK",
-    typeTransfer:    "TRANSFERENTZIAK",
-    typeTask:        "ATAZAK",
-    typePixelia:     "PIXELIA",
-    typeForum: 	     "FOROAK",
-    typeReport:      "TXOSTENAK",
-    typeFeed:        "JARIOAK",
-    typeContact:     "KONTAKTUA",
-    typePub:         "PUB-a",
-    activitySupport: "Aliantza berria sortu da",
-    activityJoin:    "PUB berria batu da",
-    question:        "Galdera",
-    deadline:        "Epemuga",
-    status:          "Egoera",
-    votes:           "Bozkak",
-    totalVotes:      "Bozkak Guztira",
-    name:            "Izena",
-    skills:          "Trebetasunak",
-    tags:            "Etiketak",
-    title:           "Izenburua",
-    date:            "Data",
-    category:        "Kategoria",
-    attendees:       "Partaide kopurua",
-    visitLink:       "Bisitatu Lotura",
-    viewDocument:    "Ikusi Dokumentua",
-    description:     "Deskribapena",
-    location:        "Kokapena",
-    contentWarning:  "Gaia",
-    personName:      "Bizilagunaren Izena",
+    activityTitle:        "Jarduera",
+    yourActivity:         "Zure jarduera",
+    globalActivity:       "Jarduera globala",
+    activityList:         "Jarduera",
+    activityDesc:         "Ikusi zure sareko azken jarduera.",
+    allButton:            "GUZTIAK",
+    mineButton:           "NIREA",
+    noActions:            "Ez dago jarduerarik.",
+    performed:            "→",
+    from:                 "Nork",
+    to:                   "Nori",
+    amount:               "Kopurua",
+    concept:              "Kontzeptua",
+    description:          "Deskribapena",
+    meme:                 "Memea",
+    activityContact:      "Kontaktua",
+    activityBy:           "Izena",
+    activityPixelia:      "Pixel berria gehituta",
+    viewImage:            "Irudia ikusi",
+    playAudio:            "Audioa erreproduzitu",
+    playVideo:            "Bideoa erreproduzitu",
+    typeRecent:           "AZKENAK",
+    errorActivity:        "Errorea jarduera eskuratzean",
+    typePost:             "ARGITALPENA",
+    typeTribe:            "TRIBUA",
+    typeAbout:            "BIZTANLEA",
+    typeCurriculum:       "CV",
+    typeImage:            "IRUDIA",
+    typeBookmark:         "LASTER-MARKA",
+    typeDocument:         "DOKUMENTUA",
+    typeVotes:            "GOBERNANTZA",
+    typeAudio:            "AUDIOA",
+    typeMarket:           "MERKATUA",
+    typeJob:              "LANA",
+    typeProject:          "PROIEKTUA",
+    typeVideo:            "BIDEOA",
+    typeVote:             "ZABALKUNDEA",
+    typeEvent:            "GERTAERA",
+    typeTransfer:         "TRANSFERENTZIA",
+    typeTask:             "ZEREGINAK",
+    typePixelia:          "PIXELIA",
+    typeForum:            "FOROA",
+    typeReport:           "TXOSTENA",
+    typeFeed:             "JARIOA",
+    typeContact:          "KONTAKTUA",
+    typePub:              "PUB",
+    typeTombstone:        "TOMBSTONE",
+    typeBanking:          "BANKUA",
+    activitySupport:      "Aliantza berria sortua",
+    activityJoin:         "PUB berri bat batu da",
+    question:             "Galdera",
+    deadline:             "Epea",
+    status:               "Egoera",
+    votes:                "Botoak",
+    totalVotes:           "Boto guztira",
+    name:                 "Izena",
+    skills:               "Gaitasunak",
+    tags:                 "Etiketak",
+    title:                "Izenburua",
+    date:                 "Data",
+    category:             "Kategoria",
+    attendees:            "Parte-hartzaileak",
+    activitySpread:       "->",
+    visitLink:            "Esteka ikusi",
+    viewDocument:         "Dokumentua ikusi",
+    location:             "Kokalekua",
+    contentWarning:       "Gaia",
+    personName:           "Biztanlearen izena",
+    typeBankWallet:       "BANKUA/ZORROA",
+    typeBankClaim:        "BANKUA/UBI",
+    bankWalletConnected:  "ECOin Zorroa",
+    bankUbiReceived:      "UBI jasota",
+    bankTx:               "Tx",
+    bankEpochShort:       "Epoka",
     //reports
     reportsTitle: "Txostenak",
     reportsDescription: "Kudeatu eta jarraitu arazo, akats, gehiegikeri eta eduki-abisuei buruzko txostena zure sarean.",
@@ -1201,6 +1212,7 @@ module.exports = {
     agendaFilterReports: "TXOSTENAK",
     agendaFilterTransfers: "TRANSFERENTZIAK",
     agendaFilterJobs: "LANPOSTUAK",
+    agendaFilterProjects: "PROIEKTUAK",
     agendaNoItems: "Esleipenik ez.",
     agendaDiscardButton: "Baztertu",
     agendaRestoreButton: "Berrezarri",
@@ -1275,18 +1287,45 @@ module.exports = {
     voteInspiring: "Inspiratzailea",
     voteSpam: "Spama",
     //inbox
-    publishBlog:          "Argitaratu Blog Bidalketa",
-    privateMessage:       "MP",
-    pmSendTitle:          "Mezu Pribatuak",
-    pmSend:               "Bidali!",
-    pmDescription:        "Erabili formulario hau mezu zifratua bidaltzeko beste bizilagunei.",
-    pmRecipients:         "Hartzaileak",
-    pmRecipientsHint:     "Sartu Oasis ID-ak, erabili koma bereizteko",
-    pmSubject:            "Gaia",
-    pmSubjectHint:        "Sartu mezuaren gaia",
-    pmText:               "Mezua",
-    pmFile:               "Gehitutako fitxategia",
-    //blockchain visor
+    publishBlog: "Bloga argitaratu",
+    privateMessage: "MP",
+    pmSendTitle: "Mezu Pribatuak",
+    pmSend: "Bidali!",
+    pmDescription: "Erabili formulario hau beste biztanleei mezu zifratua bidaltzeko.",
+    pmRecipients: "Hartzaileak",
+    pmRecipientsHint: "Idatzi Oasis IDak, komaz bereizita",
+    pmSubject: "Gaia",
+    pmSubjectHint: "Idatzi mezuaren gaia",
+    pmText: "Mezua",
+    pmFile: "Eranskina",
+    private: "Pribatuak",
+    privateDescription: "Zure mezu zifratuak.",
+    privateInbox: "Sarrerakoak",
+    privateSent: "Bidaliak",
+    privateDelete: "Ezabatu",
+    pmCreateButton: "MP idatzi",
+    noPrivateMessages: "Ez dago mezu pribaturik.",
+    performed: "egin da",
+    pmFromLabel: "Nork:",
+    pmToLabel: "Nori:",
+    pmInvalidMessage: "Mezu baliogabea",
+    pmNoSubject: "(gaia gabe)",
+    pmBotJobs: "42-JobsBOT",
+    pmBotProjects: "42-ProjectsBOT",
+    pmBotMarket: "42-MarketBOT",
+    inboxJobSubscribedTitle: "Lan-eskaintzara harpidetza berria",
+    pmInhabitantWithId: "OASIS ID duen biztanlea:",
+    pmHasSubscribedToYourJobOffer: "zure lan-eskaintzara harpidetu da",
+    inboxProjectCreatedTitle: "Proiektu berria sortu da",
+    pmHasCreatedAProject: "proiektu bat sortu du",
+    inboxMarketItemSoldTitle: "Artikulua salduta",
+    pmYourItem: "Zure artikulua",
+    pmHasBeenSoldTo: "honi saldu zaio",
+    pmFor: "truke",
+    inboxProjectPledgedTitle: "Ekarpen berria zure proiektuan",
+    pmHasPledged: "ekarpena egin du",
+    pmToYourProject: "zure proiektura",
+    //blockexplorer
     blockchain: "BlockExplorer",
     blockchainTitle: 'BlockExplorer',
     blockchainDescription: 'Blokeak aztertu eta ikusgaitu blockchain-ean.',
@@ -1305,6 +1344,70 @@ module.exports = {
     blockchainBack: 'Itzuli blokearen azterkira',
     blockchainContentDeleted: "Edukia ezabatu egin da",
     visitContent: 'Bisitatu Edukia',
+    // banking
+    banking: 'Banking',
+    bankingTitle: 'Banking',
+    bankingDescription: 'Oasis-eko biztanleentzako Oinarrizko Errenta Unibertsala, partaidetzan eta konfiantzan oinarrituta, epeka banatua.',
+    bankOverview: 'Laburpena',
+    bankEpochs: 'Epeak',
+    bankRules: 'Arauak',
+    pending: 'Zain',
+    closed: 'Itxita',
+    bankBack: 'Itzuli Banking-era',
+    bankViewTx: 'Tx ikusi',
+    bankClaimNow: 'Esleitu',
+    bankPubBalance: 'PUB saldoa',
+    bankEpoch: 'Epea',
+    bankPool: 'Funtsa (epe honetan)',
+    bankWeightsSum: 'Pisuen batura',
+    bankAllocations: 'Esleipenak',
+    bankNoAllocations: 'Ez da esleipenik aurkitu.',
+    bankNoEpochs: 'Ez da eperik aurkitu.',
+    bankEpochAllocations: 'Epearen esleipenak',
+    bankAllocId: 'Esleipen IDa',
+    bankAllocDate: 'Data',
+    bankAllocConcept: 'Kontzeptua',
+    bankAllocFrom: 'Nork',
+    bankAllocTo: 'Nori',
+    bankAllocAmount: 'Zenbatekoa',
+    bankAllocStatus: 'Egoera',
+    bankEpochId: 'Epearen IDa',
+    bankRuleHash: 'Arauen snapshot hash-a',
+    bankViewEpoch: 'Epea ikusi',
+    bankUserBalance: 'Zure saldoa',
+    ecoWalletNotConfigured: 'ECOin poltsa ez dago konfiguratuta',
+    editWallet: 'Poltsa editatu',
+    addWallet: 'Poltsa gehitu',
+    bankAddresses: 'Helbideak',
+    bankNoAddresses: 'Ez da helbiderik aurkitu.',
+    bankUser: 'Oasis ID',
+    bankAddress: 'Helbidea',
+    bankAddAddressTitle: 'ECOIN helbidea gehitu',
+    bankAddAddressUser: 'Oasis ID',
+    bankAddAddressAddress: 'ECOIN helbidea',
+    bankAddAddressSave: 'Gorde',
+    bankAddressAdded: 'Helbidea gehituta',
+    bankAddressUpdated: 'Helbidea eguneratuta',
+    bankAddressExists: 'Helbidea lehendik badago',
+    bankAddressInvalid: 'Helbide baliogabea',
+    bankAddressDeleted: 'Helbidea ezabatuta',
+    bankAddressNotFound: 'Helbiderik ez da aurkitu',
+    bankAddressTotal: 'Guztira',
+    bankAddressSearch: 'Erabiltzailea edo helbidea bilatu',
+    bankAddressActions: 'Ekintzak',
+    bankAddressDelete: 'Ezabatu',
+    bankAddressSource: 'Iturria',
+    bankAddressDeleteConfirm: 'Helbide hau ezabatu?',
+    search: 'Bilatu!',
+    bankLocal: 'Lokala',
+    bankFromOasis: 'Oasis',
+    bankCopy: 'Kopiatu',
+    bankCopied: 'Kopiatuta',
+    bankMyAddress: 'Zure helbidea',
+    bankRemoveMyAddress: 'Nire helbidea kendu',
+    bankNotRemovableOasis: 'Oasis helbideak ezin dira lokalki kendu',
+    bankingUserEngagementScore: "Konpromiso Puntuazioa",
+    bankingFutureUBI: "UBIren Estimatutako Esleipena",
     //stats
     statsTitle: 'Estatistikak',
     statistics: "Estatistikak",
@@ -1322,11 +1425,31 @@ module.exports = {
     statsNetwork: "Sarea",
     statsTotalInhabitants: "Bizilagunak",
     statsDiscoveredTribes: "Tribuak",
-    statsNetworkContent:   "Edukia",
-    statsYourMarket:       "Merkatua",
-    statsYourJob:          "Lanak",
-    statsYourTransfer:     "Transferentziak",
-    statsYourForum:        "Foroak",   
+    statsNetworkContent: "Edukia",
+    statsYourMarket: "Merkatua",
+    statsYourJob: "Lanak",
+    statsYourTransfer: "Transferentziak",
+    statsYourForum: "Foroak",   
+    statsYourProject: "Proiektuak",
+    statsDiscoveredProject: "Proiektuak",
+    statsBankingTitle: "Banking",
+    statsEcoWalletLabel: "ECOIN Zorroa",
+    statsEcoWalletNotConfigured: "Konfiguratu gabe!",
+    statsTotalEcoAddresses: "Guztira helbideak",
+    statsProject: "Proiektuak",
+    statsProject: "Proiektuak",
+    statsProjectsTitle: "Proiektuen",
+    statsProjectsTotal: "Proiektu guztira",
+    statsProjectsActive: "Aktiboak",
+    statsProjectsCompleted: "Osatuak",
+    statsProjectsPaused: "Pausatuta",
+    statsProjectsCancelled: "Ezeztatuta",
+    statsProjectsGoalTotal: "Helburu guztira",
+    statsProjectsPledgedTotal: "Konprometitutako guztira",
+    statsProjectsSuccessRate: "Arrakasta tasa",
+    statsProjectsAvgProgress: "Progresu ertaina",
+    statsProjectsMedianProgress: "Progresu medianoa",
+    statsProjectsActiveFundingAvg: "Aktibo finantzatzeko ertaina",
     statsNetworkOpinions:  "Iritziak",
     statsDiscoveredMarket: "Merkatua",
     statsDiscoveredJobs:   "Lanak",
@@ -1348,11 +1471,51 @@ module.exports = {
     statsVideos: "Bideoak",
     statsDocuments: "Dokumentuak",
     statsTransfers: "Transferentziak",
+    statsAiExchange: "IA",
     statsPosts: "Bidalketak",
     statsOasisID: "Oasis ID-a",
     statsSize: "Guztira (taimaina)",
     statsBlockchainSize: "Blockchain (tamaina)",
     statsBlobsSize: "Blob-ak (tamaina)",
+    statsActivity7d: "Aktibitatea (azken 7 egunetan)",
+    statsActivity7dTotal: "7 egunerako guztira",
+    statsActivity30dTotal: "30 egunerako guztira",
+    day: "Eguna",
+    messages: "Mezuak",
+    statsProject: "Proiektuak",
+    statsProjectsTitle: "Proiektuen",
+    statsProjectsTotal: "Proiektu guztira",
+    statsProjectsActive: "Aktiboak",
+    statsProjectsCompleted: "Osatuak",
+    statsProjectsPaused: "Pausatuta",
+    statsProjectsCancelled: "Ezeztatuta",
+    statsProjectsGoalTotal: "Helburu guztira",
+    statsProjectsPledgedTotal: "Konprometitutako guztira",
+    statsProjectsSuccessRate: "Arrakasta tasa",
+    statsProjectsAvgProgress: "Progresu ertaina",
+    statsProjectsMedianProgress: "Progresu medianoa",
+    statsProjectsActiveFundingAvg: "Aktibo finantzatzeko ertaina",
+    statsJobsTitle: "Lan",
+    statsJobsTotal: "Lan guztira",
+    statsJobsOpen: "Irekiak",
+    statsJobsClosed: "Itxita",
+    statsJobsOpenVacants: "Hutsik dauden lanak",
+    statsJobsSubscribersTotal: "Suskribatzaile guztira",
+    statsJobsAvgSalary: "Soldata ertaina",
+    statsJobsMedianSalary: "Soldata medianoa",
+    statsMarketTitle: "Merkatu",
+    statsMarketTotal: "Artikulu guztira",
+    statsMarketForSale: "Salmentan",
+    statsMarketReserved: "Erreserbatuta",
+    statsMarketClosed: "Itxita",
+    statsMarketSold: "Saltzen",
+    statsMarketRevenue: "Sarrera",
+    statsMarketAvgSoldPrice: "Salmenta bateko prezio ertaina",
+    statsUsersTitle: "Biztanleak",
+    user: "Biztanlea",
+    statsTombstoneTitle: "Hilerriak",
+    statsNetworkTombstones: "Sareko hilerriak",
+    statsTombstoneRatio: "Hilerri ratioa (%)",
     //IA
     ai: "IA",
     aiTitle: "IA",
@@ -1366,6 +1529,23 @@ module.exports = {
     aiConfiguration: "Konfiguratu prompt-a",
     aiPromptUsed: "Prompt-a",
     aiClearHistory: "Txataren historia garbitu",
+    aiSharePrompt: "Erantzun hau entrenamendu kolektibora gehitu?",
+    aiShareYes: "Bai",
+    aiShareNo: "Ez",
+    aiSharedLabel: "Entrenamendura gehituta",
+    aiRejectedLabel: "Ez da gehitu entrenamendura",
+    aiServerError: "Ezin izan da IAren erantzuna lortu. Saiatu berriro.",
+    aiInputPlaceholder: "What is Oasis?",
+    typeAiExchange: "IA",
+    aiApproveTrain: "Prestakuntza kolektibora gehitu",
+    aiRejectTrain: "Ez entrenatu",
+    aiTrainPending: "Onartzearen zain",
+    aiTrainApproved: "Entrenamendurako onartua",
+    aiTrainRejected: "Entrenamendurako baztertua",
+    aiSnippetsUsed: "Erabilitako testuinguru lerroak",
+    aiSnippetsLearned: "Ikasitako fragementuak",
+    statsAITraining: "IA prestakuntza",
+    statsAIExchanges: "Ereduen trukea",
     //market
     marketMineSectionTitle: "Zure Elementuak",
     marketCreateSectionTitle: "Sortu Elementu Berria",
@@ -1477,6 +1657,111 @@ module.exports = {
     jobTimeComplete: "Osotua",
     jobsDeleteButton: "EZABATU",
     jobsUpdateButton: "EGUNERATU",
+    //projects
+    projectsTitle: "Proiektuak",
+    projectsDescription: "Sortu, finantzatu eta jarraitu komunitateak gidatutako proiektuak zure sarean.",
+    projectCreateProject: "Proiektu Bat Sortu",
+    projectCreateButton: "Proiektu Bat Sortu",
+    projectUpdateButton: "EGUNERATU",
+    projectDeleteButton: "EZABATU",
+    projectNoProjectsFound: "Ez dira proiektuak aurkitu.",
+    projectFilterAll: "GUZTIAK",
+    projectFilterMine: "NIRE PROIEKTUAK",
+    projectFilterActive: "AKTIBOAK",
+    projectFilterPaused: "PAUSATUTAKOAK",
+    projectFilterCompleted: "OSATUTA",
+    projectFilterFollowing: "JARRAITZEN",
+    projectFilterRecent: "AZKENAK",
+    projectFilterTop: "GORA",
+    projectAllTitle: "Proiektuak",
+    projectMineTitle: "Zure Proiektuak",
+    projectActiveTitle: "Proiektu Aktiboak",
+    projectPausedTitle: "Proiektu Pausatuak",
+    projectCompletedTitle: "Proiektu Osatuak",
+    projectFollowingTitle: "Jarraitzen dituzun Proiektuak",
+    projectRecentTitle: "Proiektu Azkenak",
+    projectTopTitle: "Finantzatuenak",
+    projectTitlePlaceholder: "Proiektuaren izena",
+    projectImage: "Irudia kargatu (jpeg, jpg, png, gif) (gehienez tamaina: 500px x 400px)",
+    projectDescription: "Deskribapena",
+    projectDescriptionPlaceholder: "Kontatu istorioa eta helburuak...",
+    projectGoal: "Helburu (ECO)",
+    projectGoalPlaceholder: "50000",
+    projectDeadline: "Epea",
+    projectProgress: "Progresua hasi (%)",
+    projectStatus: "Egoera",
+    projectFunding: "Finantzaketa",
+    projectPledged: "Aginduak",
+    projectSetStatus: "Egoera ezarri",
+    projectSetProgress: "Progresua eguneratu",
+    projectFollowButton: "JARRAITU",
+    projectUnfollowButton: "JARRAITU BEHARREZ",
+    projectStatusACTIVE: "AKTIBOA",
+    projectStatusPAUSED: "PAUSATUTA",
+    projectStatusCOMPLETED: "OSATUTA",
+    projectStatusCANCELLED: "EZEZTATUTA",
+    projectPledgeTitle: "Proiektua babestu",
+    projectPledgePlaceholder: "Kantitatea ECOtan",
+    projectPledgeButton: "Babestu",
+    projectBounties: "Saria",
+    projectBountiesInputLabel: "Sariak (lerro bakoitzean: Izenburua|Kantitatea [ECO]|Deskribapena)",
+    projectBountiesPlaceholder: "UI akatsa konpondu|100|Arazoaren esteka\nDokumentuak idatzi|250|Erabilera adibideak",
+    projectNoBounties: "Ez da sariarik aurkitu.",
+    projectTitle: "Izenburua",
+    projectAddBountyTitle: "Saria berri bat gehitu",
+    projectBountyTitle: "Sariaren izenburua",
+    projectBountyAmount: "Kantitatea (ECO)",
+    projectBountyDescription: "Deskribapena",
+    projectMilestoneSelect: "Hitoa aukeratu",
+    projectBountyCreateButton: "Saria sortu",
+    projectBountyStatus: "Sariaren egoera",
+    projectBountyOpen: "irekia",
+    projectBountyClaimed: "reklamatua",
+    projectBountyDone: "osatua",
+    projectBountyClaimedBy: "reklamatua",
+    projectBountyClaimButton: "erakutsi",
+    projectBountyCompleteButton: "Markatu amaitutzat",
+    projectMilestones: "Hitoak",
+    projectAddMilestoneTitle: "Hito berria",
+    projectMilestoneTitle: "Hitoaren izenburua",
+    projectMilestoneTargetPercent: "Portzentajea (%)",
+    projectMilestoneDueDate: "Data",
+    projectMilestoneCreateButton: "Hitoa sortu",
+    projectMilestoneStatus: "Hitoaren egoera",
+    projectMilestoneOpen: "irekia",
+    projectMilestoneDone: "osatua",
+    projectMilestoneMarkDone: "Markatu amaitutzat",
+    projectMilestoneDue: "egonkor",
+    projectNoMilestones: "Ez da hitorik aurkitu.",
+    projectMilestoneMarkDone: "Markatu amaitutako bezala",
+    projectMilestoneTitlePlaceholder: "Hitoaren izenburua sartu",
+    projectMilestoneDescriptionPlaceholder: "Sartu deskribapena",
+    projectMilestoneDescription: "Hitoaren deskribapena",
+    projectBudgetGoal: "Aurrekontua (Helburua)",
+    projectBudgetAssigned: "Sarietara esleituta",
+    projectBudgetRemaining: "Geratzen dena",
+    projectBudgetOver: "⚠ Aurrekontua gaindituta: esleitutako zenbatekoak helburua gainditzen du",
+    projectFollowers: "Jarraitzaileak",
+    projectFollowersTitle: "Jarraitzaileak",
+    projectFollowersNone: "Oraindik ez dago jarraitzailerik.",
+    projectMore: "gehiago",
+    projectYouFollowHint: "Proiektu hau jarraitzen duzu",
+    projectBackers: "Babesleak",
+    projectBackersTitle: "Babesleak",
+    projectBackersTotal: "Babesleak guztira",
+    projectBackersTotalPledged: "Ekarpenen guztizkoa",
+    projectBackersYourPledge: "Zure ekarpena",
+    projectBackersNone: "Oraindik ez dago ekarpenik.",
+    projectNoRemainingBudget: "Ez da aurrekonturik geratzen.",
+    projectFilterBackers: "BABESLEAK",
+    projectBackersLeaderboardTitle: "Babesle nagusiak",
+    projectNoBackersFound: "Ez dago babeslerik.",
+    projectBackerAmount: "Ekarpen osoa",
+    projectBackerPledges: "Ekarpenak",
+    projectBackerProjects: "Proiektuak",
+    projectPledgeAmount: "Kantitatea",
+    projectSelectMilestoneOrBounty: "Hegoa edo Saria hautatu",
+    projectPledgeButton: "Ekimen egin",
     //modules
     modulesModuleName: "Izena",
     modulesModuleDescription: "Deskribapena",
@@ -1545,7 +1830,11 @@ module.exports = {
     modulesForumLabel: "Foroak",
     modulesForumDescription: "Foroak deskubritu eta kudeatzeko modulua.",
     modulesJobsLabel: "Lanpostuak",
-    modulesJobsDescription: "Lan eskaintzak aurkitu eta kudeatzeko modulu.",
+    modulesJobsDescription: "Lan eskaintzak aurkitu eta kudeatzeko modulu.",  
+    modulesProjectsLabel: "Proiektuak",
+    modulesProjectsDescription: "Proiektuak esploratzeko, finantzatzeko eta kudeatzeko modulu.",
+    modulesBankingLabel: "Bankua",
+    modulesBankingDescription: "Modulua Oinarrizko Errenta Unibertsala (OEU) bidez banatzeko, komunen diruzaintza erabiliz.",
 
      //END
   }

+ 1 - 0
src/configs/banking-allocations.json

@@ -0,0 +1 @@
+[]

+ 1 - 0
src/configs/banking-epochs.json

@@ -0,0 +1 @@
+[]

+ 9 - 2
src/configs/config-manager.js

@@ -39,14 +39,21 @@ if (!fs.existsSync(configFilePath)) {
       "agendaMod": "on",
       "aiMod": "on",
       "forumMod": "on",
-      "jobsMod": "on"
+      "jobsMod": "on",
+      "projectsMod": "on",
+      "bankingMod": "on"
     },
     "wallet": {
       "url": "http://localhost:7474",
-      "user": "ecoinrpc",
+      "user": "",
       "pass": "",
       "fee": "1"
     },
+    "walletPub": {
+      "url": "",
+      "user": "",
+      "pass": ""
+    },
     "ai": {
       "prompt": "Provide an informative and precise response."
     },

+ 10 - 3
src/configs/oasis-config.json

@@ -33,18 +33,25 @@
     "agendaMod": "on",
     "aiMod": "on",
     "forumMod": "on",
-    "jobsMod": "on"
+    "jobsMod": "on",
+    "projectsMod": "on",
+    "bankingMod": "on"
   },
   "wallet": {
     "url": "http://localhost:7474",
     "user": "ecoinrpc",
-    "pass": "",
+    "pass": "DLKKWE93203909238dkkKKeowxmIOw0232lsakwL02kUfoEcoinUfonet",
     "fee": "1"
   },
+  "walletPub": {
+    "url": "",
+    "user": "",
+    "pass": ""
+  },
   "ai": {
     "prompt": "Provide an informative and precise response."
   },
   "ssbLogStream": {
     "limit": 1000
   }
-}
+}

+ 1 - 0
src/configs/wallet-addresses.json

@@ -0,0 +1 @@
+{}

+ 32 - 18
src/models/activity_model.js

@@ -7,6 +7,12 @@ const ORDER_MARKET = ['FOR_SALE','OPEN','RESERVED','CLOSED','SOLD'];
 const SCORE_MARKET = s => {
   const i = ORDER_MARKET.indexOf(N(s));
   return i < 0 ? -1 : i;
+}
+
+const ORDER_PROJECT = ['CANCELLED','PAUSED','ACTIVE','COMPLETED'];
+const SCORE_PROJECT = s => {
+  const i = ORDER_PROJECT.indexOf(N(s));
+  return i < 0 ? -1 : i;
 };
 
 module.exports = ({ cooler }) => {
@@ -86,8 +92,16 @@ module.exports = ({ cooler }) => {
           for (const a of arr) {
             const s = SCORE_MARKET(a.content.status);
             if (s > bestScore || (s === bestScore && a.ts > tip.ts)) {
-              tip = a;
-              bestScore = s;
+              tip = a; bestScore = s;
+            }
+          }
+        } else if (type === 'project') {
+          tip = arr[0];
+          let bestScore = SCORE_PROJECT(tip.content.status);
+          for (const a of arr) {
+            const s = SCORE_PROJECT(a.content.status);
+            if (s > bestScore || (s === bestScore && a.ts > tip.ts)) {
+              tip = a; bestScore = s;
             }
           }
         } else {
@@ -120,22 +134,22 @@ module.exports = ({ cooler }) => {
       }
       latest.push({ ...a, tipId: idToTipId.get(a.id) || a.id });
     }
-
-      let out;
-      if (filter === 'mine') {
-        out = latest.filter(a => a.author === userId);
-      } else if (filter === 'recent') {
-        const cutoff = Date.now() - 24 * 60 * 60 * 1000;
-        out = latest.filter(a => (a.ts || 0) >= cutoff);
-      } else if (filter === 'all') {
-        out = latest;
-      } else {
-        out = latest.filter(a => a.type === filter);
-      }
-
-      out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
-
-      return out;
+    
+    let out;
+    if (filter === 'mine') {
+      out = latest.filter(a => a.author === userId);
+    } else if (filter === 'recent') {
+      const cutoff = Date.now() - 24 * 60 * 60 * 1000;
+      out = latest.filter(a => (a.ts || 0) >= cutoff);
+    } else if (filter === 'all') {
+      out = latest;
+    } else if (filter === 'banking') {
+      out = latest.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim');
+    } else {
+      out = latest.filter(a => a.type === filter);
+    }
+    out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
+    return out;
     }
   };
 };

+ 8 - 3
src/models/agenda_model.js

@@ -140,14 +140,15 @@ module.exports = ({ cooler }) => {
       const ssbClient = await openSsb();
       const userId = ssbClient.id;
 
-      const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll] = await Promise.all([
+      const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll] = await Promise.all([
         fetchItems('task'),
         fetchItems('event'),
         fetchItems('transfer'),
         fetchItems('tribe'),
         fetchItems('market'),
         fetchItems('report'),
-        fetchItems('job')
+        fetchItems('job'),
+        fetchItems('project')
       ]);
 
       const tasks = tasksAll.filter(c => Array.isArray(c.assignees) && c.assignees.includes(userId)).map(t => ({ ...t, type: 'task' }));
@@ -159,6 +160,7 @@ module.exports = ({ cooler }) => {
       ).map(m => ({ ...m, type: 'market' }));
       const reports = reportsAll.filter(c => c.author === userId || (Array.isArray(c.confirmations) && c.confirmations.includes(userId))).map(r => ({ ...r, type: 'report' }));
       const jobs = jobsAll.filter(c => c.author === userId || (Array.isArray(c.subscribers) && c.subscribers.includes(userId))).map(j => ({ ...j, type: 'job', title: j.title }));
+      const projects = projectsAll.map(p => ({ ...p, type: 'project' }));
 
       let combined = [
         ...tasks,
@@ -167,7 +169,8 @@ module.exports = ({ cooler }) => {
         ...tribes,
         ...marketItems,
         ...reports,
-        ...jobs
+        ...jobs,
+        ...projects
       ];
 
       let filtered;
@@ -184,6 +187,7 @@ module.exports = ({ cooler }) => {
         else if (filter === 'open') filtered = filtered.filter(i => String(i.status).toUpperCase() === 'OPEN');
         else if (filter === 'closed') filtered = filtered.filter(i => String(i.status).toUpperCase() === 'CLOSED');
         else if (filter === 'jobs') filtered = filtered.filter(i => i.type === 'job');
+        else if (filter === 'projects') filtered = filtered.filter(i => i.type === 'project');
       }
 
       filtered.sort((a, b) => {
@@ -208,6 +212,7 @@ module.exports = ({ cooler }) => {
           market: mainItems.filter(i => i.type === 'market').length,
           reports: mainItems.filter(i => i.type === 'report').length,
           jobs: mainItems.filter(i => i.type === 'job').length,
+          projects: mainItems.filter(i => i.type === 'project').length,
           discarded: discarded.length
         }
       };

+ 595 - 0
src/models/banking_model.js

@@ -0,0 +1,595 @@
+const crypto = require("crypto");
+const fs = require("fs");
+const path = require("path");
+const pull = require("../server/node_modules/pull-stream");
+const { getConfig } = require("../configs/config-manager.js");
+const { config } = require("../server/SSB_server.js");
+
+const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
+
+const DEFAULT_RULES = {
+  epochKind: "WEEKLY",
+  alpha: 0.2,
+  reserveMin: 500,
+  capPerEpoch: 2000,
+  caps: { M_max: 3, T_max: 1.5, P_max: 2, cap_user_epoch: 50, w_min: 0.2, w_max: 6 },
+  coeffs: { a1: 0.6, a2: 0.4, a3: 0.3, a4: 0.5, b1: 0.5, b2: 1.0 },
+  graceDays: 14
+};
+
+const STORAGE_DIR = path.join(__dirname, "..", "configs");
+const EPOCHS_PATH = path.join(STORAGE_DIR, "banking-epochs.json");
+const TRANSFERS_PATH = path.join(STORAGE_DIR, "banking-allocations.json");
+const ADDR_PATH = path.join(STORAGE_DIR, "wallet-addresses.json");
+
+function ensureStoreFiles() {
+  if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR, { recursive: true });
+  if (!fs.existsSync(EPOCHS_PATH)) fs.writeFileSync(EPOCHS_PATH, "[]");
+  if (!fs.existsSync(TRANSFERS_PATH)) fs.writeFileSync(TRANSFERS_PATH, "[]");
+  if (!fs.existsSync(ADDR_PATH)) fs.writeFileSync(ADDR_PATH, "{}");
+}
+
+function epochIdNow() {
+  const d = new Date();
+  const tmp = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
+  const dayNum = tmp.getUTCDay() || 7;
+  tmp.setUTCDate(tmp.getUTCDate() + 4 - dayNum);
+  const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1));
+  const weekNo = Math.ceil((((tmp - yearStart) / 86400000) + 1) / 7);
+  const yyyy = tmp.getUTCFullYear();
+  return `${yyyy}-${String(weekNo).padStart(2, "0")}`;
+}
+
+async function ensureSelfAddressPublished() {
+  const me = config.keys.id;
+  const local = readAddrMap();
+  const current = typeof local[me] === "string" ? local[me] : (local[me] && local[me].address) || null;
+  if (current && isValidEcoinAddress(current)) return { status: "present", address: current };
+  const cfg = getWalletCfg("user") || {};
+  if (!cfg.url) return { status: "skipped" };
+  try {
+    const addr = await rpcCall("getaddress", []);
+    if (addr && isValidEcoinAddress(addr)) {
+      await setUserAddress(me, addr, true);
+      return { status: "published", address: addr };
+    }
+  } catch (_) {
+    return { status: "error" };
+  }
+  return { status: "noop" };
+}
+
+function readJson(p, d) {
+  try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return d; }
+}
+
+function writeJson(p, v) {
+  fs.writeFileSync(p, JSON.stringify(v, null, 2));
+}
+
+async function rpcCall(method, params, kind = "user") {
+  const cfg = getWalletCfg(kind);
+  if (!cfg?.url) throw new Error(`${kind.toUpperCase()} RPC not configured`);
+  const headers = { "content-type": "application/json" };
+  if (cfg.user || cfg.pass) {
+    headers.authorization = "Basic " + Buffer.from(`${cfg.user}:${cfg.pass}`).toString("base64");
+  }
+  const res = await fetch(cfg.url, { method: "POST", headers, body: JSON.stringify({ jsonrpc: "1.0", id: "oasis", method, params }) });
+  if (!res.ok) throw new Error(`RPC ${method} failed`);
+  const data = await res.json();
+  if (data.error) throw new Error(data.error.message);
+  return data.result;
+}
+
+async function safeGetBalance(kind = "user") {
+  try {
+    const r = await rpcCall("getbalance", [], kind);
+    return Number(r) || 0;
+  } catch {
+    return 0;
+  }
+}
+
+function readAddrMap() {
+  ensureStoreFiles();
+  const raw = readJson(ADDR_PATH, {});
+  return raw && typeof raw === "object" ? raw : {};
+}
+
+function writeAddrMap(m) {
+  ensureStoreFiles();
+  writeJson(ADDR_PATH, m || {});
+}
+
+function getLogLimit() {
+  return getConfig().ssbLogStream?.limit || 1000;
+}
+
+function isValidEcoinAddress(addr) {
+  return typeof addr === "string" && /^[A-Za-z0-9]{20,64}$/.test(addr);
+}
+
+function getWalletCfg(kind) {
+  const cfg = getConfig() || {};
+  if (kind === "pub") {
+    return cfg.walletPub || cfg.pubWallet || (cfg.pub && cfg.pub.wallet) || null;
+  }
+  return cfg.wallet || null;
+}
+
+function resolveUserId(maybeId) {
+  const s = String(maybeId || "").trim();
+  if (s) return s;
+  return config?.keys?.id || "";
+}
+
+let FEED_SRC = "none";
+
+module.exports = ({ services } = {}) => {
+  const transfersRepo = {
+    listAll: async () => { ensureStoreFiles(); return readJson(TRANSFERS_PATH, []); },
+    listByTag: async (tag) => { ensureStoreFiles(); return readJson(TRANSFERS_PATH, []).filter(t => (t.tags || []).includes(tag)); },
+    findById: async (id) => { ensureStoreFiles(); return readJson(TRANSFERS_PATH, []).find(t => t.id === id) || null; },
+    create: async (t) => { ensureStoreFiles(); const all = readJson(TRANSFERS_PATH, []); all.push(t); writeJson(TRANSFERS_PATH, all); },
+    markClosed: async (id, txid) => { ensureStoreFiles(); const all = readJson(TRANSFERS_PATH, []); const i = all.findIndex(x => x.id === id); if (i >= 0) { all[i].status = "CLOSED"; all[i].txid = txid; writeJson(TRANSFERS_PATH, all); } }
+  };
+
+  const epochsRepo = {
+    list: async () => { ensureStoreFiles(); return readJson(EPOCHS_PATH, []); },
+    save: async (epoch) => { ensureStoreFiles(); const all = readJson(EPOCHS_PATH, []); const i = all.findIndex(e => e.id === epoch.id); if (i >= 0) all[i] = epoch; else all.push(epoch); writeJson(EPOCHS_PATH, all); },
+    get: async (id) => { ensureStoreFiles(); return readJson(EPOCHS_PATH, []).find(e => e.id === id) || null; }
+  };
+
+  async function openSsb() {
+    if (services?.ssb) return services.ssb;
+    if (services?.cooler?.open) return services.cooler.open();
+    if (global.ssb) return global.ssb;
+    try {
+      const srv = require("../server/SSB_server.js");
+      if (srv?.ssb) return srv.ssb;
+      if (srv?.server) return srv.server;
+      if (srv?.default) return srv.default;
+    } catch (_) {}
+    return null;
+  }
+
+  async function getWalletFromSSB(userId) {
+    const ssb = await openSsb();
+    if (!ssb) return null;
+    const msgs = await new Promise((resolve, reject) =>
+      pull(
+        ssb.createLogStream({ limit: getLogLimit() }),
+        pull.collect((err, arr) => err ? reject(err) : resolve(arr))
+      )
+    );
+    for (let i = msgs.length - 1; i >= 0; i--) {
+      const v = msgs[i].value || {};
+      const c = v.content || {};
+      if (v.author === userId && c && c.type === "wallet" && c.coin === "ECO" && typeof c.address === "string") {
+        return c.address;
+      }
+    }
+    return null;
+  }
+
+  async function scanAllWalletsSSB() {
+    const ssb = await openSsb();
+    if (!ssb) return {};
+    const latest = {};
+    const msgs = await new Promise((resolve, reject) =>
+      pull(
+        ssb.createLogStream({ limit: getLogLimit() }),
+        pull.collect((err, arr) => err ? reject(err) : resolve(arr))
+      )
+    );
+    for (let i = msgs.length - 1; i >= 0; i--) {
+      const v = msgs[i].value || {};
+      const c = v.content || {};
+      if (c && c.type === "wallet" && c.coin === "ECO" && typeof c.address === "string") {
+        if (!latest[v.author]) latest[v.author] = c.address;
+      }
+    }
+    return latest;
+  }
+
+  async function publishSelfAddress(address) {
+    const ssb = await openSsb();
+    if (!ssb) return false;
+    const msg = { type: "wallet", coin: "ECO", address, updatedAt: new Date().toISOString() };
+    await new Promise((resolve, reject) => ssb.publish(msg, (err, val) => err ? reject(err) : resolve(val)));
+    return true;
+  }
+
+  async function listUsers() {
+    const addrLocal = readAddrMap();
+    const ids = Object.keys(addrLocal);
+    if (ids.length > 0) return ids.map(id => ({ id }));
+    return [{ id: config.keys.id }];
+  }
+
+  async function getUserAddress(userId) {
+    const v = readAddrMap()[userId];
+    const local = typeof v === "string" ? v : (v && v.address) || null;
+    if (local) return local;
+    const ssbAddr = await getWalletFromSSB(userId);
+    return ssbAddr;
+  }
+
+  async function setUserAddress(userId, address, publishIfSelf) {
+    const m = readAddrMap();
+    m[userId] = address;
+    writeAddrMap(m);
+    if (publishIfSelf && userId === config.keys.id) await publishSelfAddress(address);
+    return true;
+  }
+
+  async function addAddress({ userId, address }) {
+    if (!userId || !address || !isValidEcoinAddress(address)) return { status: "invalid" };
+    const m = readAddrMap();
+    const prev = m[userId];
+    if (prev && (prev === address || (prev.address && prev.address === address))) return { status: "exists" };
+    m[userId] = address;
+    writeAddrMap(m);
+    if (userId === config.keys.id) await publishSelfAddress(address);
+    return { status: prev ? "updated" : "added" };
+  }
+
+  async function removeAddress({ userId }) {
+    if (!userId) return { status: "invalid" };
+    const m = readAddrMap();
+    if (!m[userId]) return { status: "not_found" };
+    delete m[userId];
+    writeAddrMap(m);
+    return { status: "deleted" };
+  }
+
+  async function listAddressesMerged() {
+    const local = readAddrMap();
+    const ssbAll = await scanAllWalletsSSB();
+    const keys = new Set([...Object.keys(local), ...Object.keys(ssbAll)]);
+    const out = [];
+    for (const id of keys) {
+      if (local[id]) out.push({ id, address: typeof local[id] === "string" ? local[id] : local[id].address, source: "local" });
+      else if (ssbAll[id]) out.push({ id, address: ssbAll[id], source: "ssb" });
+    }
+    return out;
+  }
+
+  function idsEqual(a, b) {
+    if (!a || !b) return false;
+    const A = String(a).trim();
+    const B = String(b).trim();
+    if (A === B) return true;
+    const strip = s => s.replace(/^@/, "").replace(/\.ed25519$/, "");
+    return strip(A) === strip(B);
+  }
+
+  function inferType(c = {}) {
+    if (c.vote) return "vote";
+    if (c.votes) return "votes";
+    if (c.address && c.coin === "ECO" && c.type === "wallet") return "bankWallet";
+    if (typeof c.amount !== "undefined" && c.epochId && c.allocationId) return "bankClaim";
+    if (typeof c.item_type !== "undefined" && typeof c.status !== "undefined") return "market";
+    if (typeof c.goal !== "undefined" && typeof c.progress !== "undefined") return "project";
+    if (typeof c.members !== "undefined" && typeof c.isAnonymous !== "undefined") return "tribe";
+    if (typeof c.date !== "undefined" && typeof c.location !== "undefined") return "event";
+    if (typeof c.priority !== "undefined" && typeof c.status !== "undefined" && c.title) return "task";
+    if (typeof c.confirmations !== "undefined" && typeof c.severity !== "undefined") return "report";
+    if (typeof c.job_type !== "undefined" && typeof c.status !== "undefined") return "job";
+    if (typeof c.url !== "undefined" && typeof c.mimeType !== "undefined" && c.type === "audio") return "audio";
+    if (typeof c.url !== "undefined" && typeof c.mimeType !== "undefined" && c.type === "video") return "video";
+    if (typeof c.url !== "undefined" && c.title && c.key) return "document";
+    if (typeof c.text !== "undefined" && typeof c.refeeds !== "undefined") return "feed";
+    if (typeof c.text !== "undefined" && typeof c.contentWarning !== "undefined") return "post";
+    if (typeof c.contact !== "undefined") return "contact";
+    if (typeof c.about !== "undefined") return "about";
+    if (typeof c.concept !== "undefined" && typeof c.amount !== "undefined" && c.status) return "transfer";
+    return "";
+  }
+
+  function normalizeType(a) {
+    const t = a.type || a.content?.type || inferType(a.content) || "";
+    return String(t).toLowerCase();
+  }
+
+  function priorityBump(p) {
+    const s = String(p || "").toUpperCase();
+    if (s === "HIGH") return 3;
+    if (s === "MEDIUM") return 1;
+    return 0;
+  }
+
+  function severityBump(s) {
+    const x = String(s || "").toUpperCase();
+    if (x === "CRITICAL") return 6;
+    if (x === "HIGH") return 4;
+    if (x === "MEDIUM") return 2;
+    return 0;
+  }
+
+  function scoreMarket(c) {
+    const st = String(c.status || "").toUpperCase();
+    let s = 5;
+    if (st === "SOLD") s += 8;
+    else if (st === "ACTIVE") s += 3;
+    const bids = Array.isArray(c.auctions_poll) ? c.auctions_poll.length : 0;
+    s += Math.min(10, bids);
+    return s;
+  }
+
+  function scoreProject(c) {
+    const st = String(c.status || "ACTIVE").toUpperCase();
+    const prog = Number(c.progress || 0);
+    let s = 8 + Math.min(10, prog / 10);
+    if (st === "FUNDED") s += 10;
+    return s;
+  }
+
+  function calculateOpinionScore(content) {
+    const cats = content?.opinions || {};
+    let s = 0;
+    for (const k in cats) {
+      if (!Object.prototype.hasOwnProperty.call(cats, k)) continue;
+      if (k === "interesting" || k === "inspiring") s += 5;
+      else if (k === "boring" || k === "spam" || k === "propaganda") s -= 3;
+      else s += 1;
+    }
+    return s;
+  }
+
+  async function listAllActions() {
+    if (services?.feed?.listAll) {
+      const arr = await services.feed.listAll();
+      FEED_SRC = "services.feed.listAll";
+      return normalizeFeedArray(arr);
+    }
+    if (services?.activity?.list) {
+      const arr = await services.activity.list();
+      FEED_SRC = "services.activity.list";
+      return normalizeFeedArray(arr);
+    }
+    if (typeof global.listFeed === "function") {
+      const arr = await global.listFeed("all");
+      FEED_SRC = "global.listFeed('all')";
+      return normalizeFeedArray(arr);
+    }
+    const ssb = await openSsb();
+    if (!ssb || !ssb.createLogStream) {
+      FEED_SRC = "none";
+      return [];
+    }
+    const msgs = await new Promise((resolve, reject) =>
+      pull(
+        ssb.createLogStream({ limit: getLogLimit() }),
+        pull.collect((err, arr) => err ? reject(err) : resolve(arr))
+      )
+    );
+    FEED_SRC = "ssb.createLogStream";
+    return msgs.map(m => {
+      const v = m.value || {};
+      const c = v.content || {};
+      return {
+        id: v.key || m.key,
+        author: v.author,
+        type: (c.type || "").toLowerCase(),
+        value: v,
+        content: c
+      };
+    });
+  }
+
+  function normalizeFeedArray(arr) {
+    if (!Array.isArray(arr)) return [];
+    return arr.map(x => {
+      const value = x.value || {};
+      const content = x.content || value.content || {};
+      const author = x.author || value.author || content.author || null;
+      const type = (content.type || "").toLowerCase();
+      return { id: x.id || value.key || x.key, author, type, value, content };
+    });
+  }
+
+  async function fetchUserActions(userId) {
+    const me = resolveUserId(userId);
+    const actions = await listAllActions();
+    const authored = actions.filter(a =>
+      (a.author && a.author === me) || (a.value?.author && a.value.author === me)
+    );
+    if (authored.length) return authored;
+    return actions.filter(a => {
+      const c = a.content || {};
+      const fields = [c.author, c.organizer, c.seller, c.about, c.contact];
+      return fields.some(f => f && f === me);
+    });
+  }
+
+  function scoreFromActions(actions) {
+    let score = 0;
+    for (const action of actions) {
+      const t = normalizeType(action);
+      const c = action.content || {};
+      if (t === "post") score += 10;
+      else if (t === "comment") score += 5;
+      else if (t === "like") score += 2;
+      else if (t === "image") score += 8;
+      else if (t === "video") score += 12;
+      else if (t === "audio") score += 8;
+      else if (t === "document") score += 6;
+      else if (t === "bookmark") score += 2;
+      else if (t === "feed") score += 6;
+      else if (t === "forum") score += c.root ? 5 : 10;
+      else if (t === "vote") score += 3 + calculateOpinionScore(c);
+      else if (t === "votes") score += Math.min(10, Number(c.totalVotes || 0));
+      else if (t === "market") score += scoreMarket(c);
+      else if (t === "project") score += scoreProject(c);
+      else if (t === "tribe") score += 6 + Math.min(10, Array.isArray(c.members) ? c.members.length * 0.5 : 0);
+      else if (t === "event") score += 4 + Math.min(10, Array.isArray(c.attendees) ? c.attendees.length : 0);
+      else if (t === "task") score += 3 + priorityBump(c.priority);
+      else if (t === "report") score += 4 + (Array.isArray(c.confirmations) ? c.confirmations.length : 0) + severityBump(c.severity);
+      else if (t === "curriculum") score += 5;
+      else if (t === "aiexchange") score += Array.isArray(c.ctx) ? Math.min(10, c.ctx.length) : 0;
+      else if (t === "job") score += 4 + (Array.isArray(c.subscribers) ? c.subscribers.length : 0);
+      else if (t === "bankclaim") score += Math.min(20, Math.log(1 + Math.max(0, Number(c.amount) || 0)) * 5);
+      else if (t === "bankwallet") score += 2;
+      else if (t === "transfer") score += 1;
+      else if (t === "about") score += 1;
+      else if (t === "contact") score += 1;
+      else if (t === "pub") score += 1;
+    }
+    return Math.max(0, Math.round(score));
+  }
+
+  async function getUserEngagementScore(userId) {
+    const actions = await fetchUserActions(userId);
+    return scoreFromActions(actions);
+  }
+
+  function computePoolVars(pubBal, rules) {
+    const alphaCap = (rules.alpha || DEFAULT_RULES.alpha) * pubBal;
+    const available = Math.max(0, pubBal - (rules.reserveMin || DEFAULT_RULES.reserveMin));
+    const rawMin = Math.min(available, (rules.capPerEpoch || DEFAULT_RULES.capPerEpoch), alphaCap);
+    const pool = clamp(rawMin, 0, Number.MAX_SAFE_INTEGER);
+    return { pubBal, alphaCap, available, rawMin, pool };
+  }
+
+  async function computeEpoch({ epochId, userId, rules = DEFAULT_RULES }) {
+    const pubBal = await safeGetBalance("pub");
+    const pv = computePoolVars(pubBal, rules);
+    const engagementScore = await getUserEngagementScore(userId);
+    const userWeight = 1 + engagementScore / 100;
+    const weights = [{ user: userId, w: userWeight }];
+    const W = weights.reduce((acc, x) => acc + x.w, 0) || 1;
+    const capUser = (rules.caps && rules.caps.cap_user_epoch) || DEFAULT_RULES.caps.cap_user_epoch;
+    const allocations = weights.map(({ user, w }) => {
+      const amount = Math.min(pv.pool * w / W, capUser);
+      return {
+        id: `alloc:${epochId}:${user}`,
+        epoch: epochId,
+        user,
+        weight: Number(w.toFixed(6)),
+        amount: Number(amount.toFixed(6))
+      };
+    });
+    const snapshot = JSON.stringify({ epochId, pool: pv.pool, weights, allocations, rules }, null, 2);
+    const hash = crypto.createHash("sha256").update(snapshot).digest("hex");
+    return { epoch: { id: epochId, pool: Number(pv.pool.toFixed(6)), weightsSum: Number(W.toFixed(6)), rules, hash }, allocations };
+  }
+
+  async function executeEpoch({ epochId, rules = DEFAULT_RULES }) {
+    const { epoch, allocations } = await computeEpoch({ epochId, userId: config.keys.id, rules });
+    await epochsRepo.save(epoch);
+    for (const a of allocations) {
+      if (a.amount <= 0) continue;
+      await transfersRepo.create({
+        id: a.id,
+        from: "PUB",
+        to: a.user,
+        amount: a.amount,
+        concept: `UBI ${epochId}`,
+        status: "UNCONFIRMED",
+        createdAt: new Date().toISOString(),
+        deadline: new Date(Date.now() + DEFAULT_RULES.graceDays * 86400000).toISOString(),
+        tags: ["UBI", `epoch:${epochId}`],
+        opinions: {}
+      });
+    }
+    return { epoch, allocations };
+  }
+
+  async function publishBankClaim({ amount, epochId, allocationId, txid }) {
+    const ssbClient = await openSsb();
+    const content = { type: "bankClaim", amount, epochId, allocationId, txid, timestamp: Date.now() };
+    return new Promise((resolve, reject) => ssbClient.publish(content, (err, res) => err ? reject(err) : resolve(res)));
+  }
+
+  async function claimAllocation({ transferId, claimerId, pubWalletUrl, pubWalletUser, pubWalletPass }) {
+    const allocation = await transfersRepo.findById(transferId);
+    if (!allocation || allocation.status !== "UNCONFIRMED") throw new Error("Invalid allocation or already confirmed.");
+    if (allocation.to !== claimerId) throw new Error("This allocation is not for you.");
+    const txid = await rpcCall("sendtoaddress", [pubWalletUrl, allocation.amount, "UBI claim", pubWalletUser, pubWalletPass]);
+    return { txid };
+  }
+
+  async function updateAllocationStatus(allocationId, status, txid) {
+    const all = await transfersRepo.listAll();
+    const idx = all.findIndex(t => t.id === allocationId);
+    if (idx >= 0) {
+      all[idx].status = status;
+      all[idx].txid = txid;
+      await transfersRepo.create(all[idx]);
+    }
+  }
+
+  async function listBanking(filter = "overview", userId) {
+    const uid = resolveUserId(userId);
+    const epochId = epochIdNow();
+    const pubBalance = await safeGetBalance("pub");
+    const userBalance = await safeGetBalance("user");
+    const epochs = await epochsRepo.list();
+    const all = await transfersRepo.listByTag("UBI");
+    const allocations = all.map(t => ({
+      id: t.id, concept: t.concept, from: t.from, to: t.to, amount: t.amount, status: t.status,
+      createdAt: t.createdAt || t.deadline || new Date().toISOString(), txid: t.txid
+    }));
+    let computed = null;
+    try { computed = await computeEpoch({ epochId, userId: uid, rules: DEFAULT_RULES }); } catch {}
+    const pv = computePoolVars(pubBalance, DEFAULT_RULES);
+    const actions = await fetchUserActions(uid);
+    const engagementScore = scoreFromActions(actions);
+    const poolForEpoch = computed?.epoch?.pool || pv.pool || 0;
+    const futureUBI = Number(((engagementScore / 100) * poolForEpoch).toFixed(6));
+    const addresses = await listAddressesMerged();
+    const summary = {
+      userBalance,
+      pubBalance,
+      epochId,
+      pool: poolForEpoch,
+      weightsSum: computed?.epoch?.weightsSum || 0,
+      userEngagementScore: engagementScore,
+      futureUBI
+    };
+    return { summary, allocations, epochs, rules: DEFAULT_RULES, addresses };
+  }
+
+  async function getAllocationById(id) {
+    const t = await transfersRepo.findById(id);
+    if (!t) return null;
+    return { id: t.id, concept: t.concept, from: t.from, to: t.to, amount: t.amount, status: t.status, createdAt: t.createdAt || new Date().toISOString(), txid: t.txid };
+  }
+
+  async function getEpochById(id) {
+    const existing = await epochsRepo.get(id);
+    if (existing) return existing;
+    const all = await transfersRepo.listAll();
+    const filtered = all.filter(t => (t.tags || []).includes(`epoch:${id}`));
+    const pool = filtered.reduce((s, t) => s + Number(t.amount || 0), 0);
+    return { id, pool, weightsSum: 0, rules: DEFAULT_RULES, hash: "-" };
+  }
+
+  async function listEpochAllocations(id) {
+    const all = await transfersRepo.listAll();
+    return all.filter(t => (t.tags || []).includes(`epoch:${id}`)).map(t => ({
+      id: t.id, concept: t.concept, from: t.from, to: t.to, amount: t.amount, status: t.status, createdAt: t.createdAt || new Date().toISOString(), txid: t.txid
+    }));
+  }
+
+  return {
+    DEFAULT_RULES,
+    computeEpoch,
+    executeEpoch,
+    getUserEngagementScore,
+    publishBankClaim,
+    claimAllocation,
+    listBanking,
+    getAllocationById,
+    getEpochById,
+    listEpochAllocations,
+    addAddress,
+    removeAddress,
+    ensureSelfAddressPublished,
+    getUserAddress,
+    setUserAddress,
+    listAddressesMerged
+  };
+};
+

+ 19 - 2
src/models/blockchain_model.js

@@ -14,6 +14,17 @@ module.exports = ({ cooler }) => {
   const hasBlob = async (ssbClient, url) =>
     new Promise(resolve => ssbClient.blobs.has(url, (err, has) => resolve(!err && has)));
 
+  const isClosedSold = s => String(s || '').toUpperCase() === 'SOLD' || String(s || '').toUpperCase() === 'CLOSED';
+
+  const projectRank = (status) => {
+    const S = String(status || '').toUpperCase();
+    if (S === 'COMPLETED') return 3;
+    if (S === 'ACTIVE')    return 2;
+    if (S === 'PAUSED')    return 1;
+    if (S === 'CANCELLED') return 0;
+    return -1;
+  };
+
   return {
     async listBlockchain(filter = 'all') {
       const ssbClient = await openSsb();
@@ -62,12 +73,15 @@ module.exports = ({ cooler }) => {
         let best = groupBlocks[0];
         for (const block of groupBlocks) {
           if (block.type === 'market') {
-            const isClosedSold = s => s === 'SOLD' || s === 'CLOSED';
             if (isClosedSold(block.content.status) && !isClosedSold(best.content.status)) {
               best = block;
             } else if ((block.content.status === best.content.status) && block.ts > best.ts) {
               best = block;
             }
+          } else if (block.type === 'project') {
+            const br = projectRank(best.content.status);
+            const cr = projectRank(block.content.status);
+            if (cr > br || (cr === br && block.ts > best.ts)) best = block;
           } else if (block.type === 'job' || block.type === 'forum') {
             if (block.ts > best.ts) best = block;
           } else {
@@ -148,12 +162,15 @@ module.exports = ({ cooler }) => {
         let best = groupBlocks[0];
         for (const block of groupBlocks) {
           if (block.type === 'market') {
-            const isClosedSold = s => s === 'SOLD' || s === 'CLOSED';
             if (isClosedSold(block.content.status) && !isClosedSold(best.content.status)) {
               best = block;
             } else if ((block.content.status === best.content.status) && block.ts > best.ts) {
               best = block;
             }
+          } else if (block.type === 'project') {
+            const br = projectRank(best.content.status);
+            const cr = projectRank(block.content.status);
+            if (cr > br || (cr === br && block.ts > best.ts)) best = block;
           } else if (block.type === 'job' || block.type === 'forum') {
             if (block.ts > best.ts) best = block;
           } else {

+ 258 - 199
src/models/jobs_model.js

@@ -4,206 +4,265 @@ const { getConfig } = require('../configs/config-manager.js');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
 module.exports = ({ cooler }) => {
-  let ssb
-  const openSsb = async () => {
-    if (!ssb) ssb = await cooler.open()
-    return ssb
-  }
-
-  return {
-    type: 'job',
-
-    async createJob(jobData) {
-      const ssbClient = await openSsb()
-      let blobId = jobData.image
-      if (blobId && /\(([^)]+)\)/.test(blobId)) blobId = blobId.match(/\(([^)]+)\)/)[1]
-      const content = {
-        type: 'job',
-        ...jobData,
-        image: blobId,
-        author: ssbClient.id,
-        createdAt: new Date().toISOString(),
-        status: 'OPEN',
-        subscribers: []
-      }
-      return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
-    },
-
-    async updateJob(id, jobData) {
-      const ssbClient = await openSsb()
-      const job = await this.getJobById(id)
-      if (job.author !== ssbClient.id) throw new Error('Unauthorized')
-      let blobId = jobData.image || job.image
-      if (blobId && /\(([^)]+)\)/.test(blobId)) blobId = blobId.match(/\(([^)]+)\)/)[1]
-      const tomb = { type: 'tombstone', target: job.id, deletedAt: new Date().toISOString(), author: ssbClient.id }
-      const updated = {
+    let ssb;
+    const openSsb = async () => {
+        if (!ssb) ssb = await cooler.open();
+        return ssb;
+    };
+
+    const fields = [
+        'job_type','title','description','requirements','languages',
+        'job_time','tasks','location','vacants','salary','image',
+        'author','createdAt','updatedAt','status','subscribers'
+    ];
+
+    const pickJobFields = (obj = {}) => ({
+        job_type: obj.job_type,
+        title: obj.title,
+        description: obj.description,
+        requirements: obj.requirements,
+        languages: obj.languages,
+        job_time: obj.job_time,
+        tasks: obj.tasks,
+        location: obj.location,
+        vacants: obj.vacants,
+        salary: obj.salary,
+        image: obj.image,
+        author: obj.author,
+        createdAt: obj.createdAt,
+        updatedAt: obj.updatedAt,
+        status: obj.status,
+        subscribers: Array.isArray(obj.subscribers) ? obj.subscribers : []
+    });
+
+    return {
         type: 'job',
-        ...job,
-        ...jobData,
-        image: blobId,
-        updatedAt: new Date().toISOString(),
-        replaces: job.id
-      }
-      await new Promise((res, rej) => ssbClient.publish(tomb, e => e ? rej(e) : res()))
-      return new Promise((res, rej) => ssbClient.publish(updated, (e, m) => e ? rej(e) : res(m)))
-    },
-
-    async updateJobStatus(id, status) {
-      return this.updateJob(id, { status })
-    },
-
-    async deleteJob(id) {
-      const ssbClient = await openSsb();
-      const latestId = await this.getJobTipId(id);
-      const job = await this.getJobById(latestId);
-      if (job.author !== ssbClient.id) throw new Error('Unauthorized');
-      const tomb = {
-        type: 'tombstone',
-        target: latestId,
-        deletedAt: new Date().toISOString(),
-        author: ssbClient.id
-      };
-      return new Promise((res, rej) =>
-        ssbClient.publish(tomb, (e, r) => e ? rej(e) : res(r))
-      );
-    },
-
-    async listJobs(filter) {
-      const ssbClient = await openSsb();
-      const currentUserId = ssbClient.id;
-      return new Promise((res, rej) => {
-      pull(
-      ssbClient.createLogStream({ limit: logLimit }),
-      pull.collect((e, msgs) => {
-        if (e) return rej(e);
-        const tomb = new Set();
-        const replaces = new Map();
-        const referencedAsReplaces = new Set();
-        const jobs = new Map();
-        msgs.forEach(m => {
-          const k = m.key;
-          const c = m.value.content;
-          if (!c) return;
-          if (c.type === 'tombstone' && c.target) { tomb.add(c.target); return; }
-          if (c.type !== 'job') return;
-          if (c.replaces) { replaces.set(c.replaces, k); referencedAsReplaces.add(c.replaces); }
-          jobs.set(k, { key: k, content: c });
-        });
-        const tipJobs = [];
-        for (const [id, job] of jobs.entries()) {
-          if (!referencedAsReplaces.has(id)) tipJobs.push(job);
-        }
-        const groups = {};
-        for (const job of tipJobs) {
-          const ancestor = job.content.replaces || job.key;
-          if (!groups[ancestor]) groups[ancestor] = [];
-          groups[ancestor].push(job);
-        }
 
-        const liveTipIds = new Set();
-        for (const groupJobs of Object.values(groups)) {
-          let best = groupJobs[0];
-          for (const job of groupJobs) {
-            if (
-              job.content.status === 'CLOSED' ||
-              (best.content.status !== 'CLOSED' &&
-               new Date(job.content.updatedAt || job.content.createdAt || 0) >
-               new Date(best.content.updatedAt || best.content.createdAt || 0))
-            ) {
-              best = job;
+        async createJob(jobData) {
+            const ssbClient = await openSsb();
+            let blobId = jobData.image;
+            if (blobId && /\(([^)]+)\)/.test(blobId)) blobId = blobId.match(/\(([^)]+)\)/)[1];
+            const base = pickJobFields(jobData);
+            const content = {
+                type: 'job',
+                ...base,
+                image: blobId,
+                author: ssbClient.id,
+                createdAt: new Date().toISOString(),
+                status: 'OPEN',
+                subscribers: []
+            };
+            return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)));
+        },
+
+        async updateJob(id, jobData) {
+            const ssbClient = await openSsb();
+            const current = await this.getJobById(id);
+
+            const onlySubscribersChange = Object.keys(jobData).length > 0 && Object.keys(jobData).every(k => k === 'subscribers');
+            if (!onlySubscribersChange && current.author !== ssbClient.id) throw new Error('Unauthorized');
+
+            let blobId = jobData.image ?? current.image;
+            if (blobId && /\(([^)]+)\)/.test(blobId)) blobId = blobId.match(/\(([^)]+)\)/)[1];
+
+            const patch = {};
+            for (const f of fields) {
+                if (Object.prototype.hasOwnProperty.call(jobData, f) && jobData[f] !== undefined) {
+                    patch[f] = f === 'image' ? blobId : jobData[f];
+                }
             }
-          }
-          liveTipIds.add(best.key);
-        }
-        let list = Array.from(jobs.values())
-          .filter(j => liveTipIds.has(j.key) && !tomb.has(j.key))
-          .map(j => ({ id: j.key, ...j.content }));
-        const F = String(filter).toUpperCase();
-        if (F === 'MINE')           list = list.filter(j => j.author === currentUserId);
-        else if (F === 'REMOTE')    list = list.filter(j => (j.location||'').toUpperCase() === 'REMOTE');
-        else if (F === 'PRESENCIAL')list = list.filter(j => (j.location||'').toUpperCase() === 'PRESENCIAL');
-        else if (F === 'FREELANCER')list = list.filter(j => (j.job_type||'').toUpperCase() === 'FREELANCER');
-        else if (F === 'EMPLOYEE')  list = list.filter(j => (j.job_type||'').toUpperCase() === 'EMPLOYEE');
-        else if (F === 'OPEN')      list = list.filter(j => (j.status||'').toUpperCase() === 'OPEN');
-        else if (F === 'CLOSED')    list = list.filter(j => (j.status||'').toUpperCase() === 'CLOSED');
-        else if (F === 'RECENT')    list = list.filter(j => moment(j.createdAt).isAfter(moment().subtract(24, 'hours')));
-        if (F === 'TOP') list.sort((a, b) => parseFloat(b.salary||0) - parseFloat(a.salary||0));
-        else list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
-        res(list);
-        })
-        );
-      });
-    },
-
-    async getJobById(id) {
-      const ssbClient = await openSsb();
-      const all = await new Promise((r, j) => {
-        pull(
-            ssbClient.createLogStream({ limit: logLimit }),
-            pull.collect((e, m) => e ? j(e) : r(m))
-        )
-      });
-      const tomb = new Set();
-      const replaces = new Map();
-      all.forEach(m => {
-        const c = m.value.content;
-        if (!c) return;
-        if (c.type === 'tombstone' && c.target) {
-            tomb.add(c.target);
-        } else if (c.type === 'job' && c.replaces) {
-            replaces.set(c.replaces, m.key);
-        }
-      });
-      let key = id;
-      while (replaces.has(key)) key = replaces.get(key);
-      if (tomb.has(key)) throw new Error('Job not found');
-      const msg = await new Promise((r, j) => ssbClient.get(key, (e, m) => e ? j(e) : r(m)));
-      if (!msg) throw new Error('Job not found');
-      return { id: key, ...msg.content };
-    },
-    
-    async getJobTipId(id) {
-      const ssbClient = await openSsb();
-      const all = await new Promise((r, j) => {
-        pull(
-            ssbClient.createLogStream({ limit: logLimit }),
-            pull.collect((e, m) => e ? j(e) : r(m))
-        )
-    });
-      const tomb = new Set();
-      const replaces = new Map();
-      all.forEach(m => {
-        const c = m.value.content;
-        if (!c) return;
-        if (c.type === 'tombstone' && c.target) {
-            tomb.add(c.target);
-        } else if (c.type === 'job' && c.replaces) {
-            replaces.set(c.replaces, m.key);
+
+            const next = {
+                ...current,
+                ...patch,
+                image: ('image' in patch ? blobId : current.image),
+                updatedAt: new Date().toISOString()
+            };
+
+            const tomb = {
+                type: 'tombstone',
+                target: id,
+                deletedAt: new Date().toISOString(),
+                author: ssbClient.id
+            };
+
+            const content = {
+                type: 'job',
+                job_type: next.job_type,
+                title: next.title,
+                description: next.description,
+                requirements: next.requirements,
+                languages: next.languages,
+                job_time: next.job_time,
+                tasks: next.tasks,
+                location: next.location,
+                vacants: next.vacants,
+                salary: next.salary,
+                image: next.image,
+                author: current.author,
+                createdAt: current.createdAt,
+                updatedAt: next.updatedAt,
+                status: next.status,
+                subscribers: Array.isArray(next.subscribers) ? next.subscribers : [],
+                replaces: id
+            };
+
+            await new Promise((res, rej) => ssbClient.publish(tomb, e => e ? rej(e) : res()));
+            return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)));
+        },
+
+        async updateJobStatus(id, status) {
+            return this.updateJob(id, { status });
+        },
+
+        async deleteJob(id) {
+            const ssbClient = await openSsb();
+            const latestId = await this.getJobTipId(id);
+            const job = await this.getJobById(latestId);
+            if (job.author !== ssbClient.id) throw new Error('Unauthorized');
+            const tomb = {
+                type: 'tombstone',
+                target: latestId,
+                deletedAt: new Date().toISOString(),
+                author: ssbClient.id
+            };
+            return new Promise((res, rej) => ssbClient.publish(tomb, (e, r) => e ? rej(e) : res(r)));
+        },
+
+        async listJobs(filter) {
+            const ssbClient = await openSsb();
+            const currentUserId = ssbClient.id;
+            return new Promise((res, rej) => {
+                pull(
+                    ssbClient.createLogStream({ limit: logLimit }),
+                    pull.collect((e, msgs) => {
+                        if (e) return rej(e);
+                        const tomb = new Set();
+                        const replaces = new Map();
+                        const referencedAsReplaces = new Set();
+                        const jobs = new Map();
+                        msgs.forEach(m => {
+                            const k = m.key;
+                            const c = m.value.content;
+                            if (!c) return;
+                            if (c.type === 'tombstone' && c.target) { tomb.add(c.target); return; }
+                            if (c.type !== 'job') return;
+                            if (c.replaces) { replaces.set(c.replaces, k); referencedAsReplaces.add(c.replaces); }
+                            jobs.set(k, { key: k, content: c });
+                        });
+                        const tipJobs = [];
+                        for (const [id, job] of jobs.entries()) {
+                            if (!referencedAsReplaces.has(id)) tipJobs.push(job);
+                        }
+                        const groups = {};
+                        for (const job of tipJobs) {
+                            const ancestor = job.content.replaces || job.key;
+                            if (!groups[ancestor]) groups[ancestor] = [];
+                            groups[ancestor].push(job);
+                        }
+                        const liveTipIds = new Set();
+                        for (const groupJobs of Object.values(groups)) {
+                            let best = groupJobs[0];
+                            for (const job of groupJobs) {
+                                if (
+                                    job.content.status === 'CLOSED' ||
+                                    (best.content.status !== 'CLOSED' &&
+                                        new Date(job.content.updatedAt || job.content.createdAt || 0) >
+                                        new Date(best.content.updatedAt || best.content.createdAt || 0))
+                                ) {
+                                    best = job;
+                                }
+                            }
+                            liveTipIds.add(best.key);
+                        }
+                        let list = Array.from(jobs.values())
+                            .filter(j => liveTipIds.has(j.key) && !tomb.has(j.key))
+                            .map(j => ({ id: j.key, ...j.content }));
+                        const F = String(filter).toUpperCase();
+                        if (F === 'MINE') list = list.filter(j => j.author === currentUserId);
+                        else if (F === 'REMOTE') list = list.filter(j => (j.location || '').toUpperCase() === 'REMOTE');
+                        else if (F === 'PRESENCIAL') list = list.filter(j => (j.location || '').toUpperCase() === 'PRESENCIAL');
+                        else if (F === 'FREELANCER') list = list.filter(j => (j.job_type || '').toUpperCase() === 'FREELANCER');
+                        else if (F === 'EMPLOYEE') list = list.filter(j => (j.job_type || '').toUpperCase() === 'EMPLOYEE');
+                        else if (F === 'OPEN') list = list.filter(j => (j.status || '').toUpperCase() === 'OPEN');
+                        else if (F === 'CLOSED') list = list.filter(j => (j.status || '').toUpperCase() === 'CLOSED');
+                        else if (F === 'RECENT') list = list.filter(j => moment(j.createdAt).isAfter(moment().subtract(24, 'hours')));
+                        if (F === 'TOP') list.sort((a, b) => parseFloat(b.salary || 0) - parseFloat(a.salary || 0));
+                        else list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+                        res(list);
+                    })
+                );
+            });
+        },
+
+        async getJobById(id) {
+            const ssbClient = await openSsb();
+            const all = await new Promise((r, j) => {
+                pull(
+                    ssbClient.createLogStream({ limit: logLimit }),
+                    pull.collect((e, m) => e ? j(e) : r(m))
+                );
+            });
+            const tomb = new Set();
+            const replaces = new Map();
+            all.forEach(m => {
+                const c = m.value.content;
+                if (!c) return;
+                if (c.type === 'tombstone' && c.target) tomb.add(c.target);
+                else if (c.type === 'job' && c.replaces) replaces.set(c.replaces, m.key);
+            });
+            let key = id;
+            while (replaces.has(key)) key = replaces.get(key);
+            if (tomb.has(key)) throw new Error('Job not found');
+            const msg = await new Promise((r, j) => ssbClient.get(key, (e, m) => e ? j(e) : r(m)));
+            if (!msg) throw new Error('Job not found');
+            const { id: _dropId, replaces: _dropReplaces, ...safeContent } = msg.content || {};
+            const clean = pickJobFields(safeContent);
+            return { id: key, ...clean };
+        },
+
+        async getJobTipId(id) {
+            const ssbClient = await openSsb();
+            const all = await new Promise((r, j) => {
+                pull(
+                    ssbClient.createLogStream({ limit: logLimit }),
+                    pull.collect((e, m) => e ? j(e) : r(m))
+                );
+            });
+            const tomb = new Set();
+            const replaces = new Map();
+            all.forEach(m => {
+                const c = m.value.content;
+                if (!c) return;
+                if (c.type === 'tombstone' && c.target) {
+                    tomb.add(c.target);
+                } else if (c.type === 'job' && c.replaces) {
+                    replaces.set(c.replaces, m.key);
+                }
+            });
+            let key = id;
+            while (replaces.has(key)) key = replaces.get(key);
+            if (tomb.has(key)) throw new Error('Job not found');
+            return key;
+        },
+
+        async subscribeToJob(id, userId) {
+            const latestId = await this.getJobTipId(id);
+            const job = await this.getJobById(latestId);
+            const current = Array.isArray(job.subscribers) ? job.subscribers.slice() : [];
+            if (current.includes(userId)) throw new Error('Already subscribed');
+            const next = current.concat(userId);
+            return this.updateJob(latestId, { subscribers: next });
+        },
+
+        async unsubscribeFromJob(id, userId) {
+            const latestId = await this.getJobTipId(id);
+            const job = await this.getJobById(latestId);
+            const current = Array.isArray(job.subscribers) ? job.subscribers.slice() : [];
+            if (!current.includes(userId)) throw new Error('Not subscribed');
+            const next = current.filter(uid => uid !== userId);
+            return this.updateJob(latestId, { subscribers: next });
         }
-    });
-    let key = id;
-    while (replaces.has(key)) key = replaces.get(key);
-    if (tomb.has(key)) throw new Error('Job not found');
-      return key;
-    },
-
-    async subscribeToJob(id, userId) {
-      const latestId = await this.getJobTipId(id);
-      const job = await this.getJobById(latestId);
-      if (!job.subscribers) job.subscribers = [];
-      if (job.subscribers.includes(userId)) throw new Error('Already subscribed');
-      job.subscribers.push(userId);
-      return this.updateJob(latestId, { subscribers: job.subscribers });
-    },
-
-    async unsubscribeFromJob(id, userId) {
-      const latestId = await this.getJobTipId(id);
-      const job = await this.getJobById(latestId);
-      if (!job.subscribers) job.subscribers = [];
-      if (!job.subscribers.includes(userId)) throw new Error('Not subscribed');
-      job.subscribers = job.subscribers.filter(uid => uid !== userId);
-      return this.updateJob(latestId, { subscribers: job.subscribers });
-    }
-    
-  }
-}
+    };
+};
+

+ 454 - 0
src/models/projects_model.js

@@ -0,0 +1,454 @@
+const pull = require('../server/node_modules/pull-stream')
+const moment = require('../server/node_modules/moment')
+const { getConfig } = require('../configs/config-manager.js')
+const logLimit = getConfig().ssbLogStream?.limit || 1000
+
+module.exports = ({ cooler }) => {
+  let ssb
+  const openSsb = async () => {
+    if (!ssb) ssb = await cooler.open()
+    return ssb
+  }
+
+  const TYPE = 'project'
+  const clampPercent = n => Math.max(0, Math.min(100, parseInt(n,10) || 0))
+
+  async function getAllMsgs(ssbClient) {
+    return new Promise((r, j) => {
+      pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((e, m) => e ? j(e) : r(m)))
+    })
+  }
+
+  function normalizeMilestonesFrom(data) {
+    if (Array.isArray(data.milestones)) {
+      return data.milestones.map(m => ({
+        title: String(m.title || '').trim(),
+        description: m.description || '',
+        targetPercent: clampPercent(m.targetPercent || 0),
+        dueDate: m.dueDate ? new Date(m.dueDate).toISOString() : null,
+        done: !!m.done
+      })).filter(m => m.title)
+    }
+    const title = String((data['milestones[0][title]'] || data.milestoneTitle || '')).trim()
+    const description = data['milestones[0][description]'] || data.milestoneDescription || ''
+    const tpRaw = (data['milestones[0][targetPercent]'] ?? data.milestoneTargetPercent) ?? 0
+    const targetPercent = clampPercent(tpRaw)
+    const dueRaw = data['milestones[0][dueDate]'] || data.milestoneDueDate || null
+    const dueDate = dueRaw ? new Date(dueRaw).toISOString() : null
+    const out = []
+    if (title) out.push({ title, description, targetPercent, dueDate, done: false })
+    return out
+  }
+
+  function autoCompleteMilestoneIfReady(projectLike, milestoneIdx, clampPercentFn) {
+    if (milestoneIdx == null) {
+      return { milestones: projectLike.milestones || [], progress: projectLike.progress || 0, changed: false }
+    }
+    const milestones = Array.isArray(projectLike.milestones) ? projectLike.milestones.slice() : []
+    if (!milestones[milestoneIdx]) {
+      return { milestones, progress: projectLike.progress || 0, changed: false }
+    }
+    const bounties = Array.isArray(projectLike.bounties) ? projectLike.bounties : []
+    const related = bounties.filter(b => b.milestoneIndex === milestoneIdx)
+    if (related.length === 0) {
+      return { milestones, progress: projectLike.progress || 0, changed: false }
+    }
+    const allDone = related.every(b => !!b.done)
+    let progress = projectLike.progress || 0
+    let changed = false
+    if (allDone && !milestones[milestoneIdx].done) {
+      milestones[milestoneIdx].done = true
+      const target = clampPercentFn(milestones[milestoneIdx].targetPercent || 0)
+      progress = Math.max(parseInt(progress, 10) || 0, target)
+      changed = true
+    }
+    return { milestones, progress, changed }
+  }
+
+  async function resolveTipId(id) {
+    const ssbClient = await openSsb()
+    const all = await getAllMsgs(ssbClient)
+    const tomb = new Set()
+    const replaces = new Map()
+    all.forEach(m => {
+      const c = m.value.content
+      if (!c) return
+      if (c.type === 'tombstone' && c.target) tomb.add(c.target)
+      else if (c.type === TYPE && c.replaces) replaces.set(c.replaces, m.key)
+    })
+    let key = id
+    while (replaces.has(key)) key = replaces.get(key)
+    if (tomb.has(key)) throw new Error('Project not found')
+    return key
+  }
+
+  async function getById(id) {
+    const ssbClient = await openSsb()
+    const tip = await resolveTipId(id)
+    const msg = await new Promise((r, j) => ssbClient.get(tip, (e, m) => e ? j(e) : r(m)))
+    if (!msg) throw new Error('Project not found')
+    return { id: tip, ...msg.content }
+  }
+
+  function extractBlobId(possibleMarkdownImage) {
+    let blobId = possibleMarkdownImage
+    if (blobId && /\(([^)]+)\)/.test(blobId)) blobId = blobId.match(/\(([^)]+)\)/)[1]
+    return blobId
+  }
+
+  function safeMilestoneIndex(project, idx) {
+    const total = Array.isArray(project.milestones) ? project.milestones.length : 0
+    if (idx === null || idx === undefined || idx === '' || isNaN(idx)) return null
+    const n = parseInt(idx, 10)
+    if (n < 0 || n >= total) return null
+    return n
+  }
+
+  return {
+    type: TYPE,
+
+    async createProject(data) {
+      const ssbClient = await openSsb()
+      const blobId = extractBlobId(data.image)
+      const milestones = normalizeMilestonesFrom(data)
+      const content = {
+        type: TYPE,
+        title: data.title,
+        description: data.description,
+        image: blobId || null,
+        goal: parseFloat(data.goal || 0) || 0,
+        pledged: parseFloat(data.pledged || 0) || 0,
+        deadline: data.deadline || null,
+        progress: clampPercent(data.progress || 0),
+        status: (data.status || 'ACTIVE').toUpperCase(),
+        milestones,
+        bounties: Array.isArray(data.bounties)
+          ? data.bounties.map(b => ({
+              title: String(b.title || '').trim(),
+              amount: Math.max(0, parseFloat(b.amount || 0) || 0),
+              description: b.description || '',
+              claimedBy: b.claimedBy || null,
+              done: !!b.done,
+              milestoneIndex: b.milestoneIndex != null ? parseInt(b.milestoneIndex,10) : null
+            }))
+          : [],
+        followers: [],
+        backers: [],
+        author: ssbClient.id,
+        createdAt: new Date().toISOString(),
+        updatedAt: null
+      }
+      return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
+    },
+
+    async updateProject(id, patch) {
+      const ssbClient = await openSsb()
+      const current = await getById(id)
+      if (current.author !== ssbClient.id) throw new Error('Unauthorized')
+
+      let blobId = (patch.image === undefined ? current.image : patch.image)
+      blobId = extractBlobId(blobId)
+
+      let bounties = patch.bounties === undefined ? current.bounties : patch.bounties
+      if (bounties) {
+        bounties = bounties.map(b => ({
+          title: String(b.title || '').trim(),
+          amount: Math.max(0, parseFloat(b.amount || 0) || 0),
+          description: b.description || '',
+          claimedBy: b.claimedBy || null,
+          done: !!b.done,
+          milestoneIndex: b.milestoneIndex != null ? safeMilestoneIndex(current, b.milestoneIndex) : null
+        }))
+      }
+      const tomb = { type: 'tombstone', target: current.id, deletedAt: new Date().toISOString(), author: ssbClient.id }
+      const updated = {
+        type: TYPE,
+        ...current,
+        ...patch,
+        image: blobId || null,
+        bounties,
+        updatedAt: new Date().toISOString(),
+        replaces: current.id
+      }
+      await new Promise((res, rej) => ssbClient.publish(tomb, e => e ? rej(e) : res()))
+      return new Promise((res, rej) => ssbClient.publish(updated, (e, m) => e ? rej(e) : res(m)))
+    },
+
+    async deleteProject(id) {
+      const ssbClient = await openSsb()
+      const tip = await resolveTipId(id)
+      const project = await getById(tip)
+      if (project.author !== ssbClient.id) throw new Error('Unauthorized')
+      const tomb = { type: 'tombstone', target: tip, deletedAt: new Date().toISOString(), author: ssbClient.id }
+      return new Promise((res, rej) => ssbClient.publish(tomb, (e, r) => e ? rej(e) : res(r)))
+    },
+
+    async updateProjectStatus(id, status) {
+      return this.updateProject(id, { status: String(status || '').toUpperCase() })
+    },
+
+    async updateProjectProgress(id, progress) {
+      const p = clampPercent(progress)
+      return this.updateProject(id, { progress: p, status: p >= 100 ? 'COMPLETED' : undefined })
+    },
+    
+    async getProjectById(id) {
+      const project = await projectsModel.getById(id);
+      project.backers = project.backers || [];
+      const bakers = project.backers.map(b => ({
+        userId: b.userId,
+        amount: b.amount,
+        contributedAt: moment(b.at).format('YYYY/MM/DD')
+      }));
+      return { ...project, bakers };
+    },
+    
+    async updateProjectGoalProgress(projectId, pledgeAmount) {
+     const project = await projectsModel.getById(projectId);
+     project.pledged += pledgeAmount;
+     const goalProgress = (project.pledged / project.goal) * 100;
+     await projectsModel.updateProject(projectId, { pledged: project.pledged, progress: goalProgress });
+    },
+
+    async followProject(id, userId) {
+      const tip = await this.getProjectTipId(id)
+      const project = await this.getProjectById(tip)
+      const followers = Array.isArray(project.followers) ? project.followers.slice() : []
+      if (!followers.includes(userId)) followers.push(userId)
+      return this.updateProject(tip, { followers })
+    },
+
+    async unfollowProject(id, userId) {
+      const tip = await this.getProjectTipId(id)
+      const project = await this.getProjectById(tip)
+      const followers = (project.followers || []).filter(uid => uid !== userId)
+      return this.updateProject(tip, { followers })
+    },
+
+    async pledgeToProject(id, userId, amount) {
+      openSsb().then(ssbClient => {
+        const tip = getProjectTipId(id);
+        getProjectById(tip).then(project => {
+          const amt = Math.max(0, parseFloat(amount || 0) || 0);
+          if (amt <= 0) throw new Error('Invalid amount');     
+          const backers = Array.isArray(project.backers) ? project.backers.slice() : [];
+          backers.push({ userId, amount: amt, at: new Date().toISOString() });    
+          const pledged = (parseFloat(project.pledged || 0) || 0) + amt;  
+          updateProject(tip, { backers, pledged }).then(updated => {
+            if (project.author == userId) {
+              const recipients = [project.author];
+              const content = {
+               type: 'post',
+               from: ssbClient.id,
+               to: recipients,
+               subject: 'PROJECT_PLEDGE',
+               text: `${userId} has pledged ${amt} ECO to your project "${project.title}" /projects/${encodeURIComponent(tip)}`,
+               sentAt: new Date().toISOString(),
+               private: true,
+               meta: {
+                 type: 'project-pledge',
+                 projectId: tip,
+                 projectTitle: project.title,
+                 amount: amt,
+                 pledgedBy: userId
+               }
+             };
+             ssbClient.private.publish(content, recipients);
+            }
+           return updated;
+          });
+        });
+     });
+    },
+
+    async addBounty(id, bounty) {
+      const tip = await this.getProjectTipId(id)
+      const project = await this.getProjectById(tip)
+      const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
+      const clean = {
+        title: String(bounty.title || '').trim(),
+        amount: Math.max(0, parseFloat(bounty.amount || 0) || 0),
+        description: bounty.description || '',
+        claimedBy: null,
+        done: false,
+        milestoneIndex: safeMilestoneIndex(project, bounty.milestoneIndex)
+      }
+      bounties.push(clean)
+      return this.updateProject(tip, { bounties })
+    },
+
+    async updateBounty(id, index, patch) {
+      const tip = await this.getProjectTipId(id)
+      const project = await this.getProjectById(tip)
+      const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
+      if (!bounties[index]) throw new Error('Bounty not found')
+      if (patch.title !== undefined) bounties[index].title = String(patch.title).trim()
+      if (patch.amount !== undefined) bounties[index].amount = Math.max(0, parseFloat(patch.amount || 0) || 0)
+      if (patch.description !== undefined) bounties[index].description = patch.description || ''
+      if (patch.milestoneIndex !== undefined) {
+        const newIdx = patch.milestoneIndex == null ? null : parseInt(patch.milestoneIndex, 10)
+        bounties[index].milestoneIndex = (newIdx == null) ? null : (isNaN(newIdx) ? null : newIdx)
+      }
+      if (patch.done !== undefined) bounties[index].done = !!patch.done
+      let autoPatch = {}
+      if (bounties[index].milestoneIndex != null) {
+        const { milestones, progress, changed } =
+          autoCompleteMilestoneIfReady({ ...project, bounties }, bounties[index].milestoneIndex, clampPercent)
+        if (changed) {
+          autoPatch.milestones = milestones
+          autoPatch.progress = progress
+          if (progress >= 100) autoPatch.status = 'COMPLETED'
+        }
+      }
+      return this.updateProject(tip, { bounties, ...autoPatch })
+    },
+
+    async updateMilestone(id, index, patch) {
+      const tip = await this.getProjectTipId(id)
+      const project = await this.getProjectById(tip)
+      const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : []
+      if (!milestones[index]) throw new Error('Milestone not found')
+      if (patch.title !== undefined) milestones[index].title = String(patch.title).trim()
+      if (patch.targetPercent !== undefined) milestones[index].targetPercent = clampPercent(patch.targetPercent)
+      if (patch.dueDate !== undefined) milestones[index].dueDate = patch.dueDate ? new Date(patch.dueDate).toISOString() : null
+      let progress = project.progress
+      if (patch.done !== undefined) {
+        milestones[index].done = !!patch.done
+        if (milestones[index].done) {
+          const target = clampPercent(milestones[index].targetPercent || 0)
+          progress = Math.max(parseInt(project.progress || 0, 10) || 0, target)
+        }
+      }
+      const patchOut = { milestones }
+      if (progress !== project.progress) {
+        patchOut.progress = progress
+        if (progress >= 100) patchOut.status = 'COMPLETED'
+      }
+      return this.updateProject(tip, patchOut)
+    },
+
+    async claimBounty(id, index, userId) {
+      const tip = await this.getProjectTipId(id)
+      const project = await this.getProjectById(tip)
+      const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
+      if (!bounties[index]) throw new Error('Bounty not found')
+      if (bounties[index].claimedBy) throw new Error('Already claimed')
+      bounties[index].claimedBy = userId
+      return this.updateProject(tip, { bounties })
+    },
+
+    async completeBounty(id, index, userId) {
+      const tip = await this.getProjectTipId(id)
+      const project = await this.getProjectById(tip)
+      if (project.author !== userId) throw new Error('Unauthorized')
+      const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
+      if (!bounties[index]) throw new Error('Bounty not found')
+      bounties[index].done = true
+      const { milestones, progress, changed } =
+        autoCompleteMilestoneIfReady({ ...project, bounties }, bounties[index].milestoneIndex, clampPercent)
+      const patch = { bounties }
+      if (changed) {
+        patch.milestones = milestones
+        patch.progress = progress
+        if (progress >= 100) patch.status = 'COMPLETED'
+      }
+      return this.updateProject(tip, patch)
+    },
+    
+    async addMilestone(id, milestone) {
+      const tip = await this.getProjectTipId(id)
+      const project = await this.getProjectById(tip)
+      const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : []
+      const clean = {
+        title: String(milestone.title || '').trim(),
+        description: milestone.description || '',
+        targetPercent: clampPercent(milestone.targetPercent || 0),
+        dueDate: milestone.dueDate ? new Date(milestone.dueDate).toISOString() : null,
+        done: false
+      }
+      if (!clean.title) throw new Error('Milestone title required')
+      milestones.push(clean)
+      return this.updateProject(tip, { milestones })
+    },
+
+    async completeMilestone(id, index, userId) {
+      const tip = await this.getProjectTipId(id)
+      const project = await this.getProjectById(tip)
+      if (project.author !== userId) throw new Error('Unauthorized')
+      const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : []
+      if (!milestones[index]) throw new Error('Milestone not found')
+      milestones[index].done = true
+      const target = clampPercent(milestones[index].targetPercent || 0)
+      const progress = Math.max(parseInt(project.progress || 0, 10) || 0, target)
+      const patch = { milestones, progress }
+      if (progress >= 100) patch.status = 'COMPLETED'
+      return this.updateProject(tip, patch)
+    },
+
+    async listProjects(filter) {
+      const ssbClient = await openSsb()
+      const currentUserId = ssbClient.id
+      return new Promise((res, rej) => {
+        pull(
+          ssbClient.createLogStream({ limit: logLimit }),
+          pull.collect((e, msgs) => {
+            if (e) return rej(e)
+            const tomb = new Set()
+            const replaces = new Map()
+            const referencedAsReplaces = new Set()
+            const projects = new Map()
+            msgs.forEach(m => {
+              const k = m.key
+              const c = m.value.content
+              if (!c) return
+              if (c.type === 'tombstone' && c.target) { tomb.add(c.target); return }
+              if (c.type !== TYPE) return
+              if (c.replaces) { replaces.set(c.replaces, k); referencedAsReplaces.add(c.replaces) }
+              projects.set(k, { key: k, content: c })
+            })
+            const tipProjects = []
+            for (const [id, pr] of projects.entries()) if (!referencedAsReplaces.has(id)) tipProjects.push(pr)
+            const groups = {}
+            for (const pr of tipProjects) {
+              const ancestor = pr.content.replaces || pr.key
+              if (!groups[ancestor]) groups[ancestor] = []
+              groups[ancestor].push(pr)
+            }
+            const liveTipIds = new Set()
+            for (const group of Object.values(groups)) {
+              let best = group[0]
+              for (const pr of group) {
+                const bestTime = new Date(best.content.updatedAt || best.content.createdAt || 0)
+                const prTime = new Date(pr.content.updatedAt || pr.content.createdAt || 0)
+                if (
+                  (best.content.status === 'CANCELLED' && pr.content.status !== 'CANCELLED') ||
+                  (best.content.status === pr.content.status && prTime > bestTime) ||
+                  pr.content.status === 'COMPLETED'
+                ) best = pr
+              }
+              liveTipIds.add(best.key)
+            }
+            let list = Array.from(projects.values())
+              .filter(p => liveTipIds.has(p.key) && !tomb.has(p.key))
+              .map(p => ({ id: p.key, ...p.content }))
+            const F = String(filter || 'ALL').toUpperCase()
+            if (F === 'MINE') list = list.filter(p => p.author === currentUserId)
+            else if (F === 'ACTIVE') list = list.filter(p => (p.status || '').toUpperCase() === 'ACTIVE')
+            else if (F === 'COMPLETED') list = list.filter(p => (p.status || '').toUpperCase() === 'COMPLETED')
+            else if (F === 'PAUSED') list = list.filter(p => (p.status || '').toUpperCase() === 'PAUSED')
+            else if (F === 'CANCELLED') list = list.filter(p => (p.status || '').toUpperCase() === 'CANCELLED')
+            else if (F === 'RECENT') list = list.filter(p => moment(p.createdAt).isAfter(moment().subtract(24, 'hours')))
+            else if (F === 'FOLLOWING') list = list.filter(p => Array.isArray(p.followers) && p.followers.includes(currentUserId))
+            if (F === 'TOP') list.sort((a, b) => (parseFloat(b.pledged||0)/(parseFloat(b.goal||1))) - (parseFloat(a.pledged||0)/(parseFloat(a.goal||1))))
+            else list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
+            res(list)
+          })
+        )
+      })
+    },
+
+    async getProjectById(id) { return getById(id) },
+    async getProjectTipId(id) { return resolveTipId(id) }
+  }
+}
+

+ 177 - 15
src/models/stats_model.js

@@ -1,16 +1,32 @@
 const pull = require('../server/node_modules/pull-stream');
 const os = require('os');
 const fs = require('fs');
+const path = require('path');
 const { getConfig } = require('../configs/config-manager.js');
 const logLimit = getConfig().ssbLogStream?.limit || 1000;
 
+const STORAGE_DIR = path.join(__dirname, "..", "configs");
+const ADDR_FILE = path.join(STORAGE_DIR, "wallet_addresses.json");
+
+function readAddrMap() {
+  try {
+    if (!fs.existsSync(ADDR_FILE)) return {};
+    const raw = fs.readFileSync(ADDR_FILE, 'utf8');
+    const obj = JSON.parse(raw || '{}');
+    return obj && typeof obj === 'object' ? obj : {};
+  } catch {
+    return {};
+  }
+}
+
 module.exports = ({ cooler }) => {
   let ssb;
   const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
 
   const types = [
-    'bookmark','event','task','votes','report','feed',
-    'image','audio','video','document','transfer','post','tribe','market','forum','job'
+    'bookmark','event','task','votes','report','feed','project',
+    'image','audio','video','document','transfer','post','tribe',
+    'market','forum','job','aiExchange'
   ];
 
   const getFolderSize = (folderPath) => {
@@ -18,8 +34,8 @@ module.exports = ({ cooler }) => {
     let totalSize = 0;
     for (const file of files) {
       const filePath = `${folderPath}/${file}`;
-      const stats = fs.statSync(filePath);
-      totalSize += stats.isDirectory() ? getFolderSize(filePath) : stats.size;
+      const st = fs.statSync(filePath);
+      totalSize += st.isDirectory() ? getFolderSize(filePath) : st.size;
     }
     return totalSize;
   };
@@ -33,6 +49,38 @@ module.exports = ({ cooler }) => {
     return `${(sizeInBytes / tb).toFixed(2)} TB`;
   };
 
+  const N = s => String(s || '').toUpperCase();
+  const sum = arr => arr.reduce((a, b) => a + b, 0);
+  const median = arr => {
+    if (!arr.length) return 0;
+    const a = [...arr].sort((x, y) => x - y);
+    const m = Math.floor(a.length / 2);
+    return a.length % 2 ? a[m] : (a[m - 1] + a[m]) / 2;
+  };
+
+  const parseAuctionMax = auctions_poll => {
+    if (!Array.isArray(auctions_poll) || auctions_poll.length === 0) return 0;
+    const amounts = auctions_poll.map(s => {
+      const parts = String(s).split(':');
+      const amt = parseFloat(parts[1]);
+      return isNaN(amt) ? 0 : amt;
+    });
+    return amounts.length ? Math.max(...amounts) : 0;
+  };
+
+  const dayKey = ts => new Date(ts || 0).toISOString().slice(0, 10);
+  const lastNDays = (n) => {
+    const out = [];
+    const today = new Date();
+    today.setUTCHours(0,0,0,0);
+    for (let i = n - 1; i >= 0; i--) {
+      const d = new Date(today);
+      d.setUTCDate(today.getUTCDate() - i);
+      out.push(d.toISOString().slice(0, 10));
+    }
+    return out;
+  };
+
   const getStats = async (filter = 'ALL') => {
     const ssbClient = await openSsb();
     const userId = ssbClient.id;
@@ -65,7 +113,7 @@ module.exports = ({ cooler }) => {
       const c = m.value.content;
       const t = c.type;
       if (!types.includes(t)) continue;
-      byType[t].set(k, { key: k, ts: m.value.timestamp, content: c });
+      byType[t].set(k, { key: k, ts: m.value.timestamp, content: c, author: m.value.author });
       if (c.replaces) parentOf[t].set(k, c.replaces);
     }
 
@@ -81,9 +129,7 @@ module.exports = ({ cooler }) => {
       tipOf[t] = new Map();
       const pMap = parentOf[t];
       const fwd = new Map();
-      for (const [child, parent] of pMap.entries()) {
-        fwd.set(parent, child);
-      }
+      for (const [child, parent] of pMap.entries()) fwd.set(parent, child);
       const allMap = byType[t];
       const roots = new Set(Array.from(allMap.keys()).map(id => findRoot(t, id)));
       for (const root of roots) {
@@ -99,11 +145,9 @@ module.exports = ({ cooler }) => {
     const opinions = {};
     for (const t of types) {
       let vals = Array.from(tipOf[t].values()).map(v => v.content);
-      if (t === 'forum') {
-        vals = vals.filter(c => !(c.root && tombTargets.has(c.root)));
-      }
-      content[t] = vals.length;
-      opinions[t] = vals.filter(e => Array.isArray(e.opinions_inhabitants) && e.opinions_inhabitants.length > 0).length;
+      if (t === 'forum') vals = vals.filter(c => !(c.root && tombTargets.has(c.root)));
+      content[t] = vals.length || 0;
+      opinions[t] = vals.filter(e => Array.isArray(e.opinions_inhabitants) && e.opinions_inhabitants.length > 0).length || 0;
     }
 
     const tribeVals = Array.from(tipOf['tribe'].values()).map(v => v.content);
@@ -120,7 +164,77 @@ module.exports = ({ cooler }) => {
     const flumeSize = getFolderSize(`${os.homedir()}/.ssb/flume`);
     const blobsSize = getFolderSize(`${os.homedir()}/.ssb/blobs`);
 
-    return {
+    const allTs = scopedMsgs.map(m => m.value.timestamp || 0).filter(Boolean);
+    const lastTs = allTs.length ? Math.max(...allTs) : 0;
+
+    const mapDay = new Map();
+    for (const m of scopedMsgs) {
+      const dk = dayKey(m.value.timestamp || 0);
+      mapDay.set(dk, (mapDay.get(dk) || 0) + 1);
+    }
+    const days7 = lastNDays(7).map(d => ({ day: d, count: mapDay.get(d) || 0 }));
+    const days30 = lastNDays(30).map(d => ({ day: d, count: mapDay.get(d) || 0 }));
+    const daily7Total = sum(days7.map(o => o.count));
+    const daily30Total = sum(days30.map(o => o.count));
+
+    const jobsVals = Array.from(tipOf['job'].values()).map(v => v.content);
+    const jobOpen = jobsVals.filter(j => N(j.status) === 'OPEN').length;
+    const jobClosed = jobsVals.filter(j => N(j.status) === 'CLOSED').length;
+    const jobSalaries = jobsVals.map(j => parseFloat(j.salary)).filter(n => isFinite(n));
+    const jobVacantsOpen = jobsVals.filter(j => N(j.status) === 'OPEN').map(j => parseInt(j.vacants || 0, 10) || 0);
+    const jobSubsTotal = jobsVals.map(j => Array.isArray(j.subscribers) ? j.subscribers.length : 0);
+
+    const marketVals = Array.from(tipOf['market'].values()).map(v => v.content);
+    const mkForSale = marketVals.filter(m => N(m.status) === 'FOR SALE').length;
+    const mkReserved = marketVals.filter(m => N(m.status) === 'RESERVED').length;
+    const mkClosed = marketVals.filter(m => N(m.status) === 'CLOSED').length;
+    const mkSold = marketVals.filter(m => N(m.status) === 'SOLD').length;
+    let revenueECO = 0;
+    const soldPrices = [];
+    for (const m of marketVals) {
+      if (N(m.status) !== 'SOLD') continue;
+      let price = 0;
+      if (String(m.item_type || '').toLowerCase() === 'auction') {
+        price = parseAuctionMax(m.auctions_poll);
+      } else {
+        price = parseFloat(m.price || 0) || 0;
+      }
+      soldPrices.push(price);
+      revenueECO += price;
+    }
+
+    const projectVals = Array.from(tipOf['project'].values()).map(v => v.content);
+    const prActive = projectVals.filter(p => N(p.status) === 'ACTIVE').length;
+    const prCompleted = projectVals.filter(p => N(p.status) === 'COMPLETED').length;
+    const prPaused = projectVals.filter(p => N(p.status) === 'PAUSED').length;
+    const prCancelled = projectVals.filter(p => N(p.status) === 'CANCELLED').length;
+    const prGoals = projectVals.map(p => parseFloat(p.goal || 0) || 0);
+    const prPledged = projectVals.map(p => parseFloat(p.pledged || 0) || 0);
+    const prProgress = projectVals.map(p => parseFloat(p.progress || 0) || 0);
+    const activeFundingRates = projectVals
+      .filter(p => N(p.status) === 'ACTIVE' && parseFloat(p.goal || 0) > 0)
+      .map(p => (parseFloat(p.pledged || 0) / parseFloat(p.goal || 1)) * 100);
+
+    const topAuthorsMap = new Map();
+    for (const m of scopedMsgs) {
+      const a = m.value.author;
+      topAuthorsMap.set(a, (topAuthorsMap.get(a) || 0) + 1);
+    }
+    const topAuthors = Array.from(topAuthorsMap.entries())
+      .sort((a, b) => b[1] - a[1])
+      .slice(0, 5)
+      .map(([id, count]) => ({ id, count }));
+
+    const addrMap = readAddrMap();
+    const myAddress = addrMap[userId] || null;
+    const banking = {
+      ecoWalletConfigured: !!myAddress,
+      myAddress,
+      myAddressCount: myAddress ? 1 : 0,
+      totalAddresses: Object.keys(addrMap).length
+    };
+
+    const stats = {
       id: userId,
       createdAt,
       inhabitants,
@@ -131,8 +245,56 @@ module.exports = ({ cooler }) => {
       networkTombstoneCount: allMsgs.filter(m => m.value.content.type === 'tombstone').length,
       folderSize: formatSize(folderSize),
       statsBlockchainSize: formatSize(flumeSize),
-      statsBlobsSize: formatSize(blobsSize)
+      statsBlobsSize: formatSize(blobsSize),
+      activity: {
+        lastMessageAt: lastTs ? new Date(lastTs).toISOString() : null,
+        daily7: days7,
+        daily30Total,
+        daily7Total
+      },
+      jobsKPIs: {
+        total: jobsVals.length,
+        open: jobOpen,
+        closed: jobClosed,
+        avgSalary: jobSalaries.length ? (sum(jobSalaries) / jobSalaries.length) : 0,
+        medianSalary: median(jobSalaries),
+        openVacants: sum(jobVacantsOpen),
+        subscribersTotal: sum(jobSubsTotal)
+      },
+      marketKPIs: {
+        total: marketVals.length,
+        forSale: mkForSale,
+        reserved: mkReserved,
+        closed: mkClosed,
+        sold: mkSold,
+        revenueECO,
+        avgSoldPrice: soldPrices.length ? (sum(soldPrices) / soldPrices.length) : 0
+      },
+      projectsKPIs: {
+        total: projectVals.length,
+        active: prActive,
+        completed: prCompleted,
+        paused: prPaused,
+        cancelled: prCancelled,
+        ecoGoalTotal: sum(prGoals),
+        ecoPledgedTotal: sum(prPledged),
+        successRate: projectVals.length ? (prCompleted / projectVals.length) * 100 : 0,
+        avgProgress: prProgress.length ? (sum(prProgress) / prProgress.length) : 0,
+        medianProgress: median(prProgress),
+        activeFundingAvg: activeFundingRates.length ? (sum(activeFundingRates) / activeFundingRates.length) : 0
+      },
+      usersKPIs: {
+        totalInhabitants: inhabitants,
+        topAuthors
+      },
+      tombstoneKPIs: {
+        networkTombstoneCount: allMsgs.filter(m => m.value.content.type === 'tombstone').length,
+        ratio: allMsgs.length ? (allMsgs.filter(m => m.value.content.type === 'tombstone').length / allMsgs.length) * 100 : 0
+      },
+      banking
     };
+
+    return stats;
   };
 
   return { getStats };

+ 44 - 34
src/models/wallet_model.js

@@ -1,51 +1,61 @@
 const {
-  RequestManager,
-  HTTPTransport,
-  Client } = require("../server/node_modules/@open-rpc/client-js");
+    RequestManager,
+    HTTPTransport,
+    Client
+} = require("../server/node_modules/@open-rpc/client-js");
+
+async function makeClient(url, user, pass) {
+    const headers = {};
+    if (user !== undefined || pass !== undefined) {
+        headers['Authorization'] = 'Basic ' + Buffer.from(`${user || ''}:${pass || ''}`).toString('base64');
+    }
+    const transport = new HTTPTransport(url, { headers });
+    return new Client(new RequestManager([transport]));
+}
 
 module.exports = {
     client: async (url, user, pass) => {
-      const transport = new HTTPTransport(url, {
-        headers: {
-          'Authorization': 'Basic ' + btoa(`${user}:${pass}`)
-        }
-      });
-      return new Client(new RequestManager([transport]));
+        return makeClient(url, user, pass);
     },
     execute: async (url, user, pass, method, params = []) => {
-      try {
-        const clientrpc = await module.exports.client(url, user, pass);
-        return await clientrpc.request({ method, params });
-      } catch (error) {
-        throw new Error(
-          "ECOin wallet disconnected. " +
-          "Check your wallet settings or connection status."
-        );
-      }
+        try {
+            const clientrpc = await makeClient(url, user, pass);
+            return await clientrpc.request({ method, params });
+        } catch (error) {
+            throw new Error("ECOin wallet disconnected. Check your wallet settings or connection status.");
+        }
     },
     getBalance: async (url, user, pass) => {
-      return await module.exports.execute(url, user, pass, "getbalance");
+        return Number(await module.exports.execute(url, user, pass, "getbalance")) || 0;
     },
     getAddress: async (url, user, pass) => {
-      const addresses = await module.exports.execute(url, user, pass, "getaddressesbyaccount", ['']);
-      return addresses[0]  // TODO: Handle multiple addresses
+        try {
+            const addrs = await module.exports.execute(url, user, pass, "getaddressesbyaccount", [""]);
+            if (Array.isArray(addrs) && addrs.length > 0) return addrs[0];
+        } catch {}
+        try {
+            const addr = await module.exports.execute(url, user, pass, "getnewaddress", [""]);
+            if (typeof addr === "string" && addr) return addr;
+        } catch {}
+        return "";
     },
     listTransactions: async (url, user, pass) => {
-      return await module.exports.execute(url, user, pass, "listtransactions", ["", 1000000, 0]);
+        return await module.exports.execute(url, user, pass, "listtransactions", ["", 1000000, 0]);
     },
     sendToAddress: async (url, user, pass, address, amount) => {
-      return await module.exports.execute(url, user, pass, "sendtoaddress", [address, amount]);
+        return await module.exports.execute(url, user, pass, "sendtoaddress", [address, Number(amount)]);
     },
     validateSend: async (url, user, pass, address, amount, fee) => {
-      let isValid = false
-      const errors = [];
-      const addressValid = await module.exports.execute(url, user, pass, "validateaddress", [address]);
-      const amountValid = amount > 0;
-      const feeValid = fee > 0;
-      if (!addressValid.isvalid) { errors.push("invalid_dest") }
-      if (!amountValid) { errors.push("invalid_amount") }
-      if (!feeValid) { errors.push("invalid_fee") }
-      if (errors.length == 0) { isValid = true }
-      return { isValid, errors }
+        let isValid = false;
+        const errors = [];
+        const addrInfo = await module.exports.execute(url, user, pass, "validateaddress", [address]);
+        const addressValid = !!addrInfo?.isvalid;
+        const amountValid = Number(amount) > 0;
+        const feeValid = Number(fee) >= 0;
+        if (!addressValid) errors.push("invalid_dest");
+        if (!amountValid) errors.push("invalid_amount");
+        if (!feeValid) errors.push("invalid_fee");
+        if (errors.length === 0) isValid = true;
+        return { isValid, errors };
     }
-}
+};

+ 1 - 1
src/server/package-lock.json

@@ -1,6 +1,6 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.4.3",
+  "version": "0.4.4",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {

+ 1 - 1
src/server/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.4.3",
+  "version": "0.4.4",
   "description": "Oasis Social Networking Project Utopia",
   "repository": {
     "type": "git",

+ 122 - 88
src/views/AI_view.js

@@ -1,97 +1,131 @@
-const { div, h2, p, section, button, form, textarea, br, span } = require("../server/node_modules/hyperaxe");
+const { div, h2, p, section, button, form, textarea, br, span, input } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
 const { renderUrl } = require('../backend/renderUrl');
 
 exports.aiView = (history = [], userPrompt = '') => {
-  return template(
-    i18n.aiTitle,
-    section(
-      div({ class: "tags-header" },
-        h2(i18n.aiTitle),
-        p(i18n.aiDescription),
-        userPrompt ? div({ class: 'user-prompt', style: 'margin-bottom: 2em; font-size: 0.95em; color: #888;' },
-          `${i18n.aiPromptUsed || 'System Prompt'}: `,
-          span({ style: 'font-style: italic;' }, `"${userPrompt}"`)
-        ) : null,
-        form({ method: 'POST', action: '/ai', style: "margin-bottom: 0;" },
-          textarea({ name: 'input', rows: 4, placeholder: i18n.aiInputPlaceholder, required: true }),
-          br(),
-          div({ style: "display: flex; gap: 1.5em; justify-content: flex-end; align-items: center; margin-top: 0.7em;" },
-            button({ type: 'submit' }, i18n.aiSubmitButton)
-          )
-        ),
-        div({ style: "display: flex; justify-content: flex-end; margin-bottom: 2em;" },
-          form({ method: 'POST', action: '/ai/clear', style: "display: inline;" },
-            button({
-              type: 'submit',
-              style: `
-                background: #b80c09;
-                color: #fff;
-                border: none;
-                padding: 0.4em 1.2em;
-                border-radius: 6px;
-                cursor: pointer;
-                font-size: 1em;
-                margin-left: 1em;
-              `
-            }, i18n.aiClearHistory || 'Clear chat history')
-          )
-        ),
-        br(),
-        ...history.map(entry =>
-          div({
-            class: 'chat-entry',
-            style: `
-              margin-bottom: 2em;
-              position: relative;
-              background: #191919;
-              border-radius: 10px;
-              box-shadow: 0 0 8px #0004;
-              padding-top: 1.8em;
-            `
-          },
-            entry.timestamp ? span({
-              style: `
-                position: absolute;
-                top: 0.5em;
-                right: 1.3em;
-                font-size: 0.92em;
-                color: #888;
-              `
-            }, new Date(entry.timestamp).toLocaleString()) : null,
-            br(),br(),
-            div({ class: 'user-question', style: 'margin-bottom: 0.75em;' },
-              h2(`${i18n.aiUserQuestion}:`),
-              p( ...renderUrl(entry.question))
-            ),
-            div({
-              class: 'ai-response',
-              style: `
-                max-width: 800px;
-                margin: auto;
-                background: #111;
-                padding: 1.25em;
-                border-radius: 6px;
-                font-family: sans-serif;
-                line-height: 1.6;
-                color: #ffcc00;
-              `
-            },
-              h2(`${i18n.aiResponseTitle}:`),
-              ...entry.answer
-                .split('\n\n')
-                .flatMap(paragraph =>
-                  paragraph
-                    .split('\n')
-                    .map(line =>
-                      p({ style: "margin-bottom: 1.2em;" }, ...renderUrl(line.trim()))
+    return template(
+        i18n.aiTitle,
+        section(
+            div({ class: "tags-header" },
+                h2(i18n.aiTitle),
+                p(i18n.aiDescription),
+                userPrompt ? div({ class: 'user-prompt', style: 'margin-bottom: 2em; font-size: 0.95em; color: #888;' },
+                    `${i18n.aiPromptUsed || 'System Prompt'}: `,
+                    span({ style: 'font-style: italic;' }, `"${userPrompt}"`)
+                ) : null,
+                form({ method: 'POST', action: '/ai', style: "margin-bottom: 0;" },
+                    textarea({ name: 'input', rows: 4, placeholder: i18n.aiInputPlaceholder, required: true }),
+                    br(),
+                    div({ style: "display: flex; gap: 1.5em; justify-content: flex-end; align-items: center; margin-top: 0.7em;" },
+                        button({ type: 'submit' }, i18n.aiSubmitButton)
+                    )
+                ),
+                div({ style: "display: flex; justify-content: flex-end; margin-bottom: 2em;" },
+                    form({ method: 'POST', action: '/ai/clear', style: "display: inline;" },
+                        button({
+                            type: 'submit',
+                            style: `
+                                background: #b80c09;
+                                color: #fff;
+                                border: none;
+                                padding: 0.4em 1.2em;
+                                border-radius: 6px;
+                                cursor: pointer;
+                                font-size: 1em;
+                                margin-left: 1em;
+                            `
+                        }, i18n.aiClearHistory || 'Clear chat history')
+                    )
+                ),
+                br(),
+                ...history.map(entry =>
+                    div({
+                        class: 'chat-entry',
+                        style: `
+                            margin-bottom: 2em;
+                            position: relative;
+                            background: #191919;
+                            border-radius: 10px;
+                            box-shadow: 0 0 8px #0004;
+                            padding-top: 1.8em;
+                        `
+                    },
+                        entry.timestamp ? span({
+                            style: `
+                                position: absolute;
+                                top: 0.5em;
+                                right: 1.3em;
+                                font-size: 0.92em;
+                                color: #888;
+                            `
+                        }, new Date(entry.timestamp).toLocaleString()) : null,
+                        br(), br(),
+                        div({ class: 'user-question', style: 'margin-bottom: 0.75em;' },
+                            h2(`${i18n.aiUserQuestion}:`),
+                            p(...renderUrl(entry.question))
+                        ),
+                        div({
+                            class: 'ai-response',
+                            style: `
+                                max-width: 800px;
+                                margin: auto;
+                                background: #111;
+                                padding: 1.25em;
+                                border-radius: 6px;
+                                font-family: sans-serif;
+                                line-height: 1.6;
+                                color: #ffcc00;
+                            `
+                        },
+                            h2(`${i18n.aiResponseTitle}:`),
+                            ...String(entry.answer || '')
+                                .split('\n\n')
+                                .flatMap(paragraph =>
+                                    paragraph
+                                        .split('\n')
+                                        .map(line =>
+                                            p({ style: "margin-bottom: 1.2em;" }, ...renderUrl(line.trim()))
+                                        )
+                                )
+                        ),
+                        div({
+                            class: 'ai-train-bar',
+                            style: `
+                                display:flex;
+                                align-items:center;
+                                gap:12px;
+                                margin: 12px auto 8px auto;
+                                max-width: 800px;
+                                padding: 8px 0;
+                                border-top: 1px solid #2a2a2a;
+                            `
+                        },
+                            Array.isArray(entry.snippets) && entry.snippets.length
+                                ? span({ style: 'color:#9aa; font-size:0.95em;' }, `${i18n.aiSnippetsUsed}: ${entry.snippets.length}`)
+                                : null,
+                            h2(`${i18n.statsAITraining}:`),
+                            entry.trainStatus === 'approved'
+                                ? span({ style: 'color:#5ad25a; font-weight:600;' }, i18n.aiTrainApproved)
+                                : entry.trainStatus === 'rejected'
+                                    ? span({ style: 'color:#ff6b6b; font-weight:600;' }, i18n.aiTrainRejected)
+                                    : null,
+                            entry.trainStatus === 'approved' || entry.trainStatus === 'rejected'
+                                ? null
+                                : form({ method: 'POST', action: '/ai/approve', style: 'display:inline-block;' },
+                                    input({ type: 'hidden', name: 'ts', value: String(entry.timestamp) }),
+                                    button({ type: 'submit', class: 'approve-btn', style: 'background:#1e7e34;color:#fff;border:none;padding:0.45em 0.9em;border-radius:6px;cursor:pointer;' }, i18n.aiApproveTrain)
+                                ),
+                            entry.trainStatus === 'approved' || entry.trainStatus === 'rejected'
+                                ? null
+                                : form({ method: 'POST', action: '/ai/reject', style: 'display:inline-block;' },
+                                    input({ type: 'hidden', name: 'ts', value: String(entry.timestamp) }),
+                                    button({ type: 'submit', class: 'reject-btn', style: 'background:#a71d2a;color:#fff;border:none;padding:0.45em 0.9em;border-radius:6px;cursor:pointer;' }, i18n.aiRejectTrain)
+                                )
+                        )
+                    )
                 )
-              )
             )
-          )
         )
-      )
-    )
-  );
+    );
 };
 

+ 203 - 90
src/views/activity_view.js

@@ -7,6 +7,10 @@ function capitalize(str) {
   return typeof str === 'string' && str.length ? str[0].toUpperCase() + str.slice(1) : '';
 }
 
+function sumAmounts(list = []) {
+  return list.reduce((s, x) => s + (parseFloat(x.amount || 0) || 0), 0);
+}
+
 function renderActionCards(actions, userId) {
   const validActions = actions
     .filter(action => {
@@ -71,6 +75,43 @@ function renderActionCards(actions, userId) {
         )
       );
     }
+    
+    if (type === 'bankWallet') {
+      const { address } = content;
+      cardBody.push(
+        div({ class: 'card-section banking-wallet' },
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.bankWalletConnected + ':' ),
+            span({ class: 'card-value' }, address)
+          )
+        )
+      );
+    }
+
+    if (type === 'bankClaim') {
+      const { amount, epochId, allocationId, txid } = content;
+      const amt = Number(amount || 0);
+      cardBody.push(
+        div({ class: 'card-section banking-claim' },
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.bankUbiReceived + ':' ),
+            span({ class: 'card-value' }, `${amt.toFixed(6)} ECO`)
+          ),
+          epochId ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.bankEpochShort + ':' ),
+            span({ class: 'card-value' }, epochId)
+          ) : "",
+          allocationId ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.bankAllocId + ':' ),
+            span({ class: 'card-value' }, allocationId)
+          ) : "",
+          txid ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.bankTx + ':' ),
+            a({ href: `https://ecoin.03c8.net/blockexplorer/search?q=${txid}`, target: '_blank' }, txid)
+          ) : ""
+        )
+      );
+    }
 
     if (type === 'pixelia') {
        const { author } = content;
@@ -428,6 +469,66 @@ function renderActionCards(actions, userId) {
       );
     }
     
+    if (type === 'project') {
+      const { title, status, progress, goal, pledged, deadline, followers, backers } = content;
+      const ratio = goal ? Math.min(100, Math.round((parseFloat(pledged || 0) / parseFloat(goal)) * 100)) : 0;
+      const displayStatus = String(status || 'ACTIVE').toUpperCase();
+      const followersCount = Array.isArray(followers) ? followers.length : 0;
+      const backersCount = Array.isArray(backers) ? backers.length : 0;
+      const backersTotal = sumAmounts(backers || []);
+      cardBody.push(
+        div({ class: 'card-section project' },
+          title ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.title + ':'), 
+            span({ class: 'card-value' }, title)
+          ) : "",
+          typeof goal !== 'undefined' ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.projectGoal + ':'), 
+            span({ class: 'card-value' }, `${goal} ECO`)
+          ) : "",
+          typeof progress !== 'undefined' ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.projectProgress + ':'), 
+            span({ class: 'card-value' }, `${progress || 0}%`)
+          ) : "",
+          deadline ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.projectDeadline + ':'), 
+            span({ class: 'card-value' }, moment(deadline).format('YYYY/MM/DD HH:mm'))
+          ) : "",
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.projectStatus + ':'), 
+            span({ class: 'card-value' }, i18n['projectStatus' + displayStatus] || displayStatus)
+          ),
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.projectFunding + ':'), 
+            span({ class: 'card-value' }, `${ratio}%`)
+          ),
+          typeof pledged !== 'undefined' ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.projectPledged + ':'), 
+            span({ class: 'card-value' }, `${pledged || 0} ECO`)
+          ) : "",
+          div({ class: 'card-field' },
+            span({ class: 'card-label' }, i18n.projectFollowers + ':'), 
+            span({ class: 'card-value' }, `${followersCount}`)
+          ),
+          div({ class: 'card-field' },
+           span({ class: 'card-label' }, i18n.projectBackers + ':'), 
+           span({ class: 'card-value' }, `${backersCount} · ${backersTotal} ECO`)
+          )
+        )
+      );
+    }
+  
+    if (type === 'aiExchange') {
+      const { ctx } = content;
+      cardBody.push(
+        div({ class: 'card-section ai-exchange' },
+          Array.isArray(ctx) && ctx.length
+            ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.aiSnippetsLearned || 'Snippets learned') + ':'), span({ class: 'card-value' }, String(ctx.length)))
+            : ""
+        )
+      );
+    }
+    
     if (type === 'job') {
       const { title, job_type, tasks, location, vacants, salary, status, subscribers } = content;
       cardBody.push(
@@ -471,11 +572,11 @@ function renderActionCards(actions, userId) {
 return div({ class: 'card card-rpg' },
   div({ class: 'card-header' },
     h2({ class: 'card-label' }, `[${typeLabel}]`),
-	type !== 'feed' && (!action.tipId || action.tipId === action.id)
-	  ? form({ method: "GET", action: getViewDetailsAction(type, action) },
-	      button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
-	    )
-	  : ''
+    type !== 'feed' && type !== 'aiExchange' && type !== 'bankWallet' && (!action.tipId || action.tipId === action.id)
+      ? form({ method: "GET", action: getViewDetailsAction(type, action) },
+          button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        )
+      : ''
   ),
   div({ class: 'card-body' }, ...cardBody),
   p({ class: 'card-footer' },
@@ -511,7 +612,11 @@ function getViewDetailsAction(type, action) {
     case 'pub': return `/invites`;
     case 'market': return `/market/${id}`;
     case 'job': return `/jobs/${id}`;
+    case 'project': return `/projects/${id}`;
     case 'report': return `/reports/${id}`;
+    case 'bankWallet': return `/wallet`;
+    case 'bankClaim': return `/banking${action.content?.epochId ? `/epoch/${encodeURIComponent(action.content.epochId)}` : ''}`;
+
   }
 }
 
@@ -520,105 +625,113 @@ exports.activityView = (actions, filter, userId) => {
   const desc = i18n.activityDesc;
 
   const activityTypes = [
-    { type: 'recent', label: i18n.typeRecent },
-    { type: 'all', label: i18n.allButton },
-    { type: 'mine', label: i18n.mineButton },
-    { type: 'votes', label: i18n.typeVotes },
-    { type: 'event', label: i18n.typeEvent },
-    { type: 'task', label: i18n.typeTask },
-    { type: 'report', label: i18n.typeReport },
-    { type: 'tribe', label: i18n.typeTribe },
-    { type: 'about', label: i18n.typeAbout },
-    { type: 'curriculum', label: i18n.typeCurriculum },
-    { type: 'market', label: i18n.typeMarket },
-    { type: 'job', label: i18n.typeJob },
-    { type: 'transfer', label: i18n.typeTransfer },
-    { type: 'feed', label: i18n.typeFeed },
-    { type: 'post', label: i18n.typePost },
-    { type: 'pixelia', label: i18n.typePixelia },
-    { type: 'forum', label: i18n.typeForum },
-    { type: 'bookmark', label: i18n.typeBookmark },
-    { type: 'image', label: i18n.typeImage },
-    { type: 'video', label: i18n.typeVideo },
-    { type: 'audio', label: i18n.typeAudio },
-    { type: 'document', label: i18n.typeDocument }
+  { type: 'recent',    label: i18n.typeRecent },
+  { type: 'all',       label: i18n.allButton },
+  { type: 'mine',      label: i18n.mineButton },
+
+  { type: 'banking',   label: i18n.typeBanking },
+  { type: 'market',    label: i18n.typeMarket },
+  { type: 'project',   label: i18n.typeProject },
+  { type: 'job',       label: i18n.typeJob },
+  { type: 'transfer',  label: i18n.typeTransfer },
+
+  { type: 'votes',     label: i18n.typeVotes },
+  { type: 'event',     label: i18n.typeEvent },
+  { type: 'task',      label: i18n.typeTask },
+  { type: 'report',    label: i18n.typeReport },
+
+  { type: 'tribe',     label: i18n.typeTribe },
+  { type: 'about',     label: i18n.typeAbout },
+  { type: 'curriculum',label: i18n.typeCurriculum },
+  { type: 'feed',      label: i18n.typeFeed },
+
+  { type: 'aiExchange', label: i18n.typeAiExchange },
+  { type: 'post',      label: i18n.typePost },
+  { type: 'pixelia',   label: i18n.typePixelia },
+  { type: 'forum',     label: i18n.typeForum },
+  
+  { type: 'bookmark',  label: i18n.typeBookmark },
+  { type: 'image',     label: i18n.typeImage },
+  { type: 'video',     label: i18n.typeVideo },
+  { type: 'audio',     label: i18n.typeAudio },
+  { type: 'document',  label: i18n.typeDocument }
   ];
-  let filteredActions;
+
+ let filteredActions;
   if (filter === 'mine') {
-    filteredActions = actions.filter(action => actions.author === userId && action.type !== 'tombstone');
+    filteredActions = actions.filter(action => action.author === userId && action.type !== 'tombstone');
   } else if (filter === 'recent') {
     const now = Date.now();
-    filteredActions = actions.filter(action => 
-      action.type !== 'tombstone' && action.ts && now - action.ts < 24 * 60 * 60 * 1000 
-    );
+    filteredActions = actions.filter(action => action.type !== 'tombstone' && action.ts && now - action.ts < 24 * 60 * 60 * 1000);
+  } else if (filter === 'banking') {
+    filteredActions = actions.filter(action => action.type !== 'tombstone' && (action.type === 'bankWallet' || action.type === 'bankClaim'));
   } else {
     filteredActions = actions.filter(action => (action.type === filter || filter === 'all') && action.type !== 'tombstone');
   }
 
   let html = template(
-    title,
-    section(
-      div({ class: 'tags-header' },
-        h2(i18n.activityList),
-        p(desc)
+  title,
+  section(
+    div({ class: 'tags-header' },
+      h2(i18n.activityList),
+      p(desc)
+    ),
+    form({ method: 'GET', action: '/activity' },
+      div({ class: 'mode-buttons', style: 'display:grid; grid-template-columns: repeat(6, 1fr); gap: 16px; margin-bottom: 24px;' },
+        div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
+          activityTypes.slice(0, 3).map(({ type, label }) =>
+            form({ method: 'GET', action: '/activity' },
+              input({ type: 'hidden', name: 'filter', value: type }),
+              button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
+            )
+          )
         ),
-        form({ method: 'GET', action: '/activity' },
-          div({ class: 'mode-buttons', style: 'display:grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 24px;' },
-            div({
-              style: 'display: flex; flex-direction: column; gap: 8px;'
-            },
-              activityTypes.slice(0, 3).map(({ type, label }) =>
-                form({ method: 'GET', action: '/activity' },
-                  input({ type: 'hidden', name: 'filter', value: type }),
-                  button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
-                )
-              )
-            ),
-            div({
-              style: 'display: flex; flex-direction: column; gap: 8px;'
-            },
-              activityTypes.slice(3, 7).map(({ type, label }) =>
-                form({ method: 'GET', action: '/activity' },
-                  input({ type: 'hidden', name: 'filter', value: type }),
-                  button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
-                )
-              )
-            ),
-            div({
-              style: 'display: flex; flex-direction: column; gap: 8px;'
-            },
-              activityTypes.slice(7, 11).map(({ type, label }) =>
-                form({ method: 'GET', action: '/activity' },
-                  input({ type: 'hidden', name: 'filter', value: type }),
-                  button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
-                )
-              )
-            ),
-            div({
-              style: 'display: flex; flex-direction: column; gap: 8px;'
-            },
-              activityTypes.slice(11, 16).map(({ type, label }) =>
-                form({ method: 'GET', action: '/activity' },
-                  input({ type: 'hidden', name: 'filter', value: type }),
-                  button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
-                )
-              )
-            ),
-            div({
-              style: 'display: flex; flex-direction: column; gap: 8px;'
-            },
-              activityTypes.slice(16, 22).map(({ type, label }) =>
-                form({ method: 'GET', action: '/activity' },
-                  input({ type: 'hidden', name: 'filter', value: type }),
-                  button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
-                )
-              )
+        div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
+          activityTypes.slice(3, 8).map(({ type, label }) =>
+            form({ method: 'GET', action: '/activity' },
+              input({ type: 'hidden', name: 'filter', value: type }),
+              button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
+            )
+          )
+        ),
+        div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
+          activityTypes.slice(8, 12).map(({ type, label }) =>
+            form({ method: 'GET', action: '/activity' },
+              input({ type: 'hidden', name: 'filter', value: type }),
+              button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
+            )
+          )
+        ),
+        div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
+          activityTypes.slice(12, 16).map(({ type, label }) =>
+            form({ method: 'GET', action: '/activity' },
+              input({ type: 'hidden', name: 'filter', value: type }),
+              button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
             )
           )
         ),
-       section({ class: 'feed-container' }, renderActionCards(filteredActions, userId))
-    )
+        div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
+          activityTypes.slice(16, 20).map(({ type, label }) =>
+            form({ method: 'GET', action: '/activity' },
+              input({ type: 'hidden', name: 'filter', value: type }),
+              button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
+            )
+          )
+        ),
+        div({ style: 'display: flex; flex-direction: column; gap: 8px;' },
+          activityTypes.slice(20, 25).map(({ type, label }) =>
+            form({ method: 'GET', action: '/activity' },
+              input({ type: 'hidden', name: 'filter', value: type }),
+              button({ type: 'submit', class: filter === type ? 'filter-btn active' : 'filter-btn' }, label)
+            )
+          )
+        )
+      )
+    ),
+    section({ class: 'feed-container' }, renderActionCards(filteredActions, userId))
+  )
   );
+
   const hasDocument = actions.some(a => a && a.type === 'document');
   if (hasDocument) {
     html += `

+ 15 - 2
src/views/agenda_view.js

@@ -23,6 +23,7 @@ function getViewDetailsAction(item) {
     case 'market': return `/market/${encodeURIComponent(item.id)}`;
     case 'report': return `/reports/${encodeURIComponent(item.id)}`;
     case 'job': return `/jobs/${encodeURIComponent(item.id)}`;
+    case 'project': return `/projects/${encodeURIComponent(item.id)}`;
     default: return `/messages/${encodeURIComponent(item.id)}`;
   }
 }
@@ -129,6 +130,16 @@ const renderAgendaItem = (item, userId, filter) => {
     const membersList = item.to ? p(a({ class: "user-link", href: `/author/${encodeURIComponent(item.to)}` }, item.to)) : '';
     details.push(div({ class: 'members-list' }, i18n.to + ': ', membersList));
   }
+  
+  if (item.type === 'project') {
+    details = [
+      renderCardField(i18n.projectStatus + ":", item.status || i18n.noStatus),
+      renderCardField(i18n.projectProgress + ":", `${item.progress || 0}%`),
+      renderCardField(i18n.projectGoal + ":", `${item.goal} ECO`),
+      renderCardField(i18n.projectPledged + ":", `${item.pledged || 0} ECO`),
+      renderCardField(i18n.projectDeadline + ":", item.deadline ? new Date(item.deadline).toLocaleString() : i18n.noDeadline)
+    ];
+  }
 
   if (item.type === 'job') {
     const subs = Array.isArray(item.subscribers)
@@ -204,12 +215,14 @@ exports.agendaView = async (data, filter) => {
             `${i18n.agendaFilterReports} (${counts.reports})`),
           button({ type: 'submit', name: 'filter', value: 'tribes', class: filter === 'tribes' ? 'filter-btn active' : 'filter-btn' },
             `${i18n.agendaFilterTribes} (${counts.tribes})`),
+          button({ type: 'submit', name: 'filter', value: 'jobs', class: filter === 'jobs' ? 'filter-btn active' : 'filter-btn' },
+            `${i18n.agendaFilterJobs} (${counts.jobs})`),
           button({ type: 'submit', name: 'filter', value: 'market', class: filter === 'market' ? 'filter-btn active' : 'filter-btn' },
             `${i18n.agendaFilterMarket} (${counts.market})`),
+          button({ type: 'submit', name: 'filter', value: 'projects', class: filter === 'projects' ? 'filter-btn active' : 'filter-btn' },
+            `${i18n.agendaFilterProjects} (${counts.projects})`),
           button({ type: 'submit', name: 'filter', value: 'transfers', class: filter === 'transfers' ? 'filter-btn active' : 'filter-btn' },
             `${i18n.agendaFilterTransfers} (${counts.transfers})`),
-          button({ type: 'submit', name: 'filter', value: 'jobs', class: filter === 'jobs' ? 'filter-btn active' : 'filter-btn' },
-            `${i18n.agendaFilterJobs} (${counts.jobs})`),
           button({ type: 'submit', name: 'filter', value: 'discarded', class: filter === 'discarded' ? 'filter-btn active' : 'filter-btn' },
             `DISCARDED (${counts.discarded})`)
         )

+ 259 - 0
src/views/banking_views.js

@@ -0,0 +1,259 @@
+const { div, h2, p, section, button, form, a, input, span, pre, table, thead, tbody, tr, td, th, br } = require("../server/node_modules/hyperaxe");
+const { template, i18n } = require("../views/main_views");
+
+const FILTER_LABELS = {
+  overview: i18n.bankOverview,
+  mine: i18n.mine,
+  pending: i18n.pending,
+  closed: i18n.closed,
+  epochs: i18n.bankEpochs,
+  rules: i18n.bankRules,
+  addresses: i18n.bankAddresses
+};
+
+const generateFilterButtons = (filters, currentFilter, action) =>
+  div({ class: "mode-buttons-row" },
+    ...filters.map(mode =>
+      form({ method: "GET", action },
+        input({ type: "hidden", name: "filter", value: mode }),
+        button({ type: "submit", class: currentFilter === mode ? "filter-btn active" : "filter-btn" }, (FILTER_LABELS[mode] || mode).toUpperCase())
+      )
+    )
+  );
+
+const kvRow = (label, value) =>
+  tr(td({ class: "card-label" }, label), td({ class: "card-value" }, value));
+
+const renderOverviewSummaryTable = (s, rules) => {
+  const score = Number(s.userEngagementScore || 0);
+  const pool = Number(s.pool || 0);
+  const W = Math.max(1, Number(s.weightsSum || 1));
+  const w = 1 + score / 100;
+  const cap = rules?.caps?.cap_user_epoch ?? 50;
+  const future = Math.min(pool * (w / W), cap);
+
+  return div({ class: "bank-summary" },
+    table({ class: "bank-info-table" },
+      tbody(
+        kvRow(i18n.bankUserBalance, `${Number(s.userBalance || 0).toFixed(6)} ECO`),
+        kvRow(i18n.bankPubBalance, `${Number(s.pubBalance || 0).toFixed(6)} ECO`),
+        kvRow(i18n.bankEpoch, String(s.epochId || "-")),
+        kvRow(i18n.bankPool, `${pool.toFixed(6)} ECO`),
+        kvRow(i18n.bankWeightsSum, String(W.toFixed(6))),
+        kvRow(i18n.bankingUserEngagementScore, String(score)),
+        kvRow(i18n.bankingFutureUBI, `${future.toFixed(6)} ECO`)
+      )
+    )
+  );
+};
+
+function calculateFutureUBI(userEngagementScore, poolAmount) {
+  const maxScore = 100;
+  const scorePercentage = userEngagementScore / maxScore;
+  const estimatedUBI = poolAmount * scorePercentage;
+  return estimatedUBI;
+}
+
+const filterAllocations = (allocs, filter, userId) => {
+  if (filter === "mine") return allocs.filter(a => a.to === userId && a.status === "UNCONFIRMED");
+  if (filter === "pending") return allocs.filter(a => a.status === "UNCONFIRMED");
+  if (filter === "closed") return allocs.filter(a => a.status === "CLOSED");
+  return allocs;
+};
+
+const allocationsTable = (rows = [], userId) =>
+  rows.length === 0
+    ? div(p(i18n.bankNoAllocations))
+    : table(
+        { class: "bank-allocs" },
+        thead(
+          tr(
+            th(i18n.bankAllocDate),
+            th(i18n.bankAllocConcept),
+            th(i18n.bankAllocFrom),
+            th(i18n.bankAllocTo),
+            th(i18n.bankAllocAmount),
+            th(i18n.bankAllocStatus),
+            th("")
+          )
+        ),
+        tbody(
+          ...rows.map(r =>
+            tr(
+              td(new Date(r.createdAt).toLocaleString()),
+              td(r.concept || ""),
+              td(a({ href: `/author/${encodeURIComponent(r.from)}`, class: "user-link" }, r.from)),
+              td(a({ href: `/author/${encodeURIComponent(r.to)}`, class: "user-link" }, r.to)),
+              td(String(Number(r.amount || 0).toFixed(6))),
+              td(r.status),
+              td(
+                r.status === "UNCONFIRMED" && r.to === userId
+                  ? form({ method: "POST", action: `/banking/claim/${encodeURIComponent(r.id)}` },
+                      button({ type: "submit", class: "filter-btn" }, i18n.bankClaimNow)
+                    )
+                  : r.status === "CLOSED" && r.txid
+                    ? a({ href: `https://ecoin.03c8.net/blockexplorer/search?q=${encodeURIComponent(r.txid)}`, target: "_blank", class: "btn-singleview" }, i18n.bankViewTx)
+                    : null
+              )
+            )
+          )
+        )
+      );
+
+const renderEpochList = (epochs = []) =>
+  epochs.length === 0
+    ? div(p(i18n.bankNoEpochs))
+    : table(
+        { class: "bank-epochs" },
+        thead(tr(th(i18n.bankEpochId), th(i18n.bankPool), th(i18n.bankWeightsSum), th(i18n.bankRuleHash), th(""))),
+        tbody(
+          ...epochs
+            .sort((a, b) => String(b.id).localeCompare(String(a.id)))
+            .map(e =>
+              tr(
+                td(e.id),
+                td(String(Number(e.pool || 0).toFixed(6))),
+                td(String(Number(e.weightsSum || 0).toFixed(6))),
+                td(e.hash || "-"),
+                td(
+                  form({ method: "GET", action: `/banking/epoch/${encodeURIComponent(e.id)}` },
+                    button({ type: "submit", class: "filter-btn" }, i18n.bankViewEpoch)
+                  )
+                )
+              )
+            )
+        )
+      );
+
+const rulesBlock = (rules) =>
+  div({ class: "bank-rules" }, pre({ class: "json-content" }, JSON.stringify(rules || {}, null, 2)));
+
+const flashText = (key) => {
+  if (key === "added") return i18n.bankAddressAdded;
+  if (key === "updated") return i18n.bankAddressUpdated;
+  if (key === "exists") return i18n.bankAddressExists;
+  if (key === "invalid") return i18n.bankAddressInvalid;
+  if (key === "deleted") return i18n.bankAddressDeleted;
+  if (key === "not_found") return i18n.bankAddressNotFound;
+  return "";
+};
+
+const flashBanner = (msgKey) =>
+  !msgKey ? null : div({ class: "flash-banner" }, p(flashText(msgKey)));
+
+const addressesToolbar = (rows = [], search = "") =>
+  div({ class: "addr-toolbar" },
+    div({ class: "addr-counter accent-pill" },
+      span({ class: "acc-title accent" }, i18n.bankAddressTotal + ":"),
+      span({ class: "acc-badge" }, String(rows.length))
+    ),
+    form({ method: "GET", action: "/banking", class: "addr-search" },
+      input({ type: "hidden", name: "filter", value: "addresses" }),
+      input({ type: "text", name: "q", placeholder: i18n.bankAddressSearch, value: search || "" }),
+      br(),
+      button({ type: "submit", class: "filter-btn" }, i18n.search)
+    )
+  );
+
+const renderAddresses = (data, userId) => {
+  const rows = data.addresses || [];
+  const search = data.search || "";
+  return div(
+    data.flash ? flashBanner(data.flash) : null,
+    addressesToolbar(rows, search),
+    div({ class: "bank-addresses-stack" },
+      div({ class: "addr-form-card wide" },
+        h2(i18n.bankAddAddressTitle),
+        form({ method: "POST", action: "/banking/addresses", class: "addr-form" },
+          div({ class: "form-row" },
+            span({ class: "form-label accent" }, i18n.bankAddAddressUser + ":"),
+            input({
+              class: "form-input xl",
+              type: "text",
+              name: "userId",
+              required: true,
+              pattern: "^@[A-Za-z0-9+/]+={0,2}\\.ed25519$",
+              placeholder: "@...=.ed25519",
+              id: "addr-user-id"
+            })
+          ),
+          div({ class: "form-row" },
+            span({ class: "form-label accent" }, i18n.bankAddAddressAddress + ":"),
+            input({
+              class: "form-input xl",
+              type: "text",
+              name: "address",
+              required: true,
+              pattern: "^[A-Za-z0-9]{20,64}$",
+              placeholder: "ETQ17sBv8QFoiCPGKDQzNcDJeXmB2317HX"
+            })
+          ),
+          div({ class: "form-actions" },
+            button({ type: "submit", class: "filter-btn" }, i18n.bankAddAddressSave)
+          )
+        )
+      ),
+      div({ class: "addr-list-card" },
+        rows.length === 0
+          ? div(p(i18n.bankNoAddresses))
+          : table(
+              { class: "bank-addresses" },
+              thead(
+                tr(
+                  th(i18n.bankUser),
+                  th(i18n.bankAddress),
+                  th(i18n.bankAddressSource),
+                  th(i18n.bankAddressActions)
+                )
+              ),
+              tbody(
+                ...rows.map(r =>
+                  tr(
+                    td(a({ href: `/author/${encodeURIComponent(r.id)}`, class: "user-link" }, r.id)),
+                    td(r.address),
+                    td(r.source === "local" ? i18n.bankLocal : i18n.bankFromOasis),
+                    td(
+                      div({ class: "row-actions" },
+                        r.source === "local"
+                          ? form({ method: "POST", action: "/banking/addresses/delete", class: "addr-del" },
+                              input({ type: "hidden", name: "userId", value: r.id }),
+                              button({ type: "submit", class: "delete-btn", onclick: `return confirm(${JSON.stringify(i18n.bankAddressDeleteConfirm)})` }, i18n.bankAddressDelete)
+                            )
+                          : null
+                      )
+                    )
+                  )
+                )
+              )
+            )
+      )
+    )
+  );
+};
+
+const renderBankingView = (data, filter, userId) =>
+  template(
+    i18n.banking,
+    section(
+      div({ class: "tags-header" }, h2(i18n.banking), p(i18n.bankingDescription)),
+      generateFilterButtons(["overview", "mine", "pending", "closed", "epochs", "rules", "addresses"], filter, "/banking"),
+      filter === "overview"
+        ? div(
+            renderOverviewSummaryTable(data.summary || {}, data.rules),
+            allocationsTable((data.allocations || []).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), userId)
+          )
+        : filter === "epochs"
+          ? renderEpochList(data.epochs || [])
+          : filter === "rules"
+            ? rulesBlock(data.rules)
+            : filter === "addresses"
+              ? renderAddresses(data, userId)
+              : allocationsTable(
+                  (filterAllocations((data.allocations || []).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), filter, userId)),
+                  userId
+                )
+    )
+  );
+
+module.exports = { renderBankingView };
+

+ 58 - 52
src/views/blockchain_view.js

@@ -9,19 +9,22 @@ const FILTER_LABELS = {
     feed: i18n.typeFeed, event: i18n.typeEvent, task: i18n.typeTask, report: i18n.typeReport,
     image: i18n.typeImage, audio: i18n.typeAudio, video: i18n.typeVideo, post: i18n.typePost,
     forum: i18n.typeForum, about: i18n.typeAbout, contact: i18n.typeContact, pub: i18n.typePub,
-    transfer: i18n.typeTransfer, market: i18n.typeMarket, job: i18n.typeJob, tribe: i18n.typeTribe
+    transfer: i18n.typeTransfer, market: i18n.typeMarket, job: i18n.typeJob, tribe: i18n.typeTribe,
+    project: i18n.typeProject, banking: i18n.typeBanking, bankWallet: i18n.typeBankWallet, bankClaim: i18n.typeBankClaim,
+    aiExchange: i18n.typeAiExchange,
 };
 
 const BASE_FILTERS = ['recent', 'all', 'mine', 'tombstone'];
 const CAT_BLOCK1  = ['votes', 'event', 'task', 'report'];
-const CAT_BLOCK2  = ['pub', 'tribe', 'about', 'contact', 'curriculum', 'vote'];
-const CAT_BLOCK3  = ['market', 'job', 'transfer', 'feed', 'post', 'pixelia'];
+const CAT_BLOCK2  = ['pub', 'tribe', 'about', 'contact', 'curriculum', 'vote', 'aiExchange'];
+const CAT_BLOCK3  = ['banking', 'job', 'market', 'project', 'transfer', 'feed', 'post', 'pixelia'];
 const CAT_BLOCK4  = ['forum', 'bookmark', 'image', 'video', 'audio', 'document'];
 
 const filterBlocks = (blocks, filter, userId) => {
     if (filter === 'recent') return blocks.filter(b => Date.now() - b.ts < 24*60*60*1000);
-    if (filter === 'mine')   return blocks.filter(b => b.author === userId);
-    if (filter === 'all')    return blocks;
+    if (filter === 'mine') return blocks.filter(b => b.author === userId);
+    if (filter === 'all') return blocks;
+    if (filter === 'banking') return blocks.filter(b => b.type === 'bankWallet' || b.type === 'bankClaim');
     return blocks.filter(b => b.type === filter);
 };
 
@@ -40,28 +43,31 @@ const generateFilterButtons = (filters, currentFilter, action) =>
 
 const getViewDetailsAction = (type, block) => {
     switch (type) {
-        case 'votes':      return `/votes/${encodeURIComponent(block.id)}`;
-        case 'transfer':   return `/transfers/${encodeURIComponent(block.id)}`;
-        case 'pixelia':    return `/pixelia`;
-        case 'tribe':      return `/tribe/${encodeURIComponent(block.id)}`;
+        case 'votes': return `/votes/${encodeURIComponent(block.id)}`;
+        case 'transfer': return `/transfers/${encodeURIComponent(block.id)}`;
+        case 'pixelia': return `/pixelia`;
+        case 'tribe': return `/tribe/${encodeURIComponent(block.id)}`;
         case 'curriculum': return `/inhabitant/${encodeURIComponent(block.author)}`;
-        case 'image':      return `/images/${encodeURIComponent(block.id)}`;
-        case 'audio':      return `/audios/${encodeURIComponent(block.id)}`;
-        case 'video':      return `/videos/${encodeURIComponent(block.id)}`;
-        case 'forum':      return `/forum/${encodeURIComponent(block.content?.key||block.id)}`;
-        case 'document':   return `/documents/${encodeURIComponent(block.id)}`;
-        case 'bookmark':   return `/bookmarks/${encodeURIComponent(block.id)}`;
-        case 'event':      return `/events/${encodeURIComponent(block.id)}`;
-        case 'task':       return `/tasks/${encodeURIComponent(block.id)}`;
-        case 'about':      return `/author/${encodeURIComponent(block.author)}`;
-        case 'post':       return `/thread/${encodeURIComponent(block.id)}#${encodeURIComponent(block.id)}`;
-        case 'vote':       return `/thread/${encodeURIComponent(block.content.vote.link)}#${encodeURIComponent(block.content.vote.link)}`;
-        case 'contact':    return `/inhabitants`;
-        case 'pub':        return `/invites`;
-        case 'market':     return `/market/${encodeURIComponent(block.id)}`;
-        case 'job':        return `/jobs/${encodeURIComponent(block.id)}`;
-        case 'report':     return `/reports/${encodeURIComponent(block.id)}`;
-        default:           return null;
+        case 'image': return `/images/${encodeURIComponent(block.id)}`;
+        case 'audio': return `/audios/${encodeURIComponent(block.id)}`;
+        case 'video': return `/videos/${encodeURIComponent(block.id)}`;
+        case 'forum': return `/forum/${encodeURIComponent(block.content?.key||block.id)}`;
+        case 'document': return `/documents/${encodeURIComponent(block.id)}`;
+        case 'bookmark': return `/bookmarks/${encodeURIComponent(block.id)}`;
+        case 'event': return `/events/${encodeURIComponent(block.id)}`;
+        case 'task': return `/tasks/${encodeURIComponent(block.id)}`;
+        case 'about': return `/author/${encodeURIComponent(block.author)}`;
+        case 'post': return `/thread/${encodeURIComponent(block.id)}#${encodeURIComponent(block.id)}`;
+        case 'vote': return `/thread/${encodeURIComponent(block.content.vote.link)}#${encodeURIComponent(block.content.vote.link)}`;
+        case 'contact': return `/inhabitants`;
+        case 'pub': return `/invites`;
+        case 'market': return `/market/${encodeURIComponent(block.id)}`;
+        case 'job': return `/jobs/${encodeURIComponent(block.id)}`;
+        case 'project': return `/projects/${encodeURIComponent(block.id)}`;
+        case 'report': return `/reports/${encodeURIComponent(block.id)}`;
+        case 'bankWallet': return `/wallet`;
+        case 'bankClaim': return `/banking${block.content?.epochId ? `/epoch/${encodeURIComponent(block.content.epochId)}` : ''}`;
+        default: return null;
     }
 };
 
@@ -106,20 +112,20 @@ const renderSingleBlockView = (block, filter) =>
                     pre({ class:'json-content' }, JSON.stringify(block.content,null,2))
                 )
             ),
-	   div({ class:'block-row block-row--back' },
-	    form({ method:'GET', action:'/blockexplorer' },
-		button({ type:'submit', class:'filter-btn' }, `← ${i18n.blockchainBack}`)
-	    ),
-	    !block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
-		form({ method:'GET', action:getViewDetailsAction(block.type, block) },
-		    button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
-		)
-	    : (block.isTombstoned || block.isReplaced) ?
-		div({ class: 'deleted-label', style: 'color:#b00;font-weight:bold;margin-top:8px;' },
-		    i18n.blockchainContentDeleted || "This content has been deleted."
-		)
-	    : null
-	    )
+            div({ class:'block-row block-row--back' },
+                form({ method:'GET', action:'/blockexplorer' },
+                    button({ type:'submit', class:'filter-btn' }, `← ${i18n.blockchainBack}`)
+                ),
+                !block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
+                    form({ method:'GET', action:getViewDetailsAction(block.type, block) },
+                        button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
+                    )
+                : (block.isTombstoned || block.isReplaced) ?
+                    div({ class: 'deleted-label', style: 'color:#b00;font-weight:bold;margin-top:8px;' },
+                        i18n.blockchainContentDeleted || "This content has been deleted."
+                    )
+                : null
+            )
         )
     );
 
@@ -158,18 +164,18 @@ const renderBlockchainView = (blocks, filter, userId) =>
                     })
                     .map(block=>
                         div({ class:'block' },
-			   div({ class:'block-buttons' },
-			    a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}`, class:'btn-singleview', title:i18n.blockchainDetails },'⦿'),
-			    !block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
-			    form({ method:'GET', action:getViewDetailsAction(block.type, block) },
-				button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
-			    )
-			    : (block.isTombstoned || block.isReplaced) ?
-			    div({ class: 'deleted-label', style: 'color:#b00;font-weight:bold;margin-top:8px;' },
-				i18n.blockchainContentDeleted || "This content has been deleted."
-			    )
-			    : null
-			    ),			
+                            div({ class:'block-buttons' },
+                                a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}`, class:'btn-singleview', title:i18n.blockchainDetails },'⦿'),
+                                !block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
+                                    form({ method:'GET', action:getViewDetailsAction(block.type, block) },
+                                        button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
+                                    )
+                                : (block.isTombstoned || block.isReplaced) ?
+                                    div({ class: 'deleted-label', style: 'color:#b00;font-weight:bold;margin-top:8px;' },
+                                        i18n.blockchainContentDeleted || "This content has been deleted."
+                                    )
+                                : null
+                            ),
                             div({ class:'block-row block-row--meta' },
                                 table({ class:'block-info-table' },
                                     tr(td({ class:'card-label' }, i18n.blockchainBlockTimestamp), td({ class:'card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ'))),

+ 1 - 1
src/views/feed_view.js

@@ -87,7 +87,7 @@ exports.feedView = (feeds, filter) => {
     title,
     section(
       header,
-      div({ class: 'mode-buttons', style: 'display:flex; gap:8px; margin-bottom:24px;' },
+      div({ class: 'mode-buttons-row' },
         generateFilterButtons(['ALL', 'MINE', 'TODAY', 'TOP'], filter, '/feed'),
         form({ method: 'GET', action: '/feed/create' },
           button({

+ 371 - 227
src/views/main_views.js

@@ -21,7 +21,7 @@ const getUserId = async () => {
   return userId;
 };
 
-const { a, article, br, body, button, details, div, em, footer, form, h1, h2, head, header, hr, html, img, input, label, li, link, main, meta, nav, option, p, pre, section, select, span, summary, textarea, title, tr, ul } = require("../server/node_modules/hyperaxe");
+const { a, article, br, body, button, details, div, em, footer, form, h1, h2, head, header, hr, html, img, input, label, li, link, main, meta, nav, option, p, pre, section, select, span, summary, textarea, title, tr, ul, strong } = require("../server/node_modules/hyperaxe");
 
 const lodash = require("../server/node_modules/lodash");
 const markdown = require("./markdown");
@@ -236,7 +236,6 @@ const renderMarketLink = () => {
   const marketMod = getConfig().modules.marketMod === 'on';
   return marketMod 
     ? [
-      hr(),
       navLink({ href: "/market", emoji: "ꕻ", text: i18n.marketTitle }),
       ]
     : '';
@@ -251,6 +250,25 @@ const renderJobsLink = () => {
     : '';
 };
 
+const renderProjectsLink = () => {
+  const projectsMod = getConfig().modules.projectsMod === 'on';
+  return projectsMod 
+    ? [
+      navLink({ href: "/projects", emoji: "ꕧ", text: i18n.projectsTitle }),
+      ]
+    : '';
+};
+
+const renderBankingLink = () => {
+  const bankingMod = getConfig().modules.bankingMod === 'on';
+  return bankingMod 
+    ? [
+      hr(),
+      navLink({ href: "/banking", emoji: "ꗴ", text: i18n.bankingTitle }),
+      ]
+    : '';
+};
+
 const renderTribesLink = () => {
   const tribesMod = getConfig().modules.tribesMod === 'on';
   return tribesMod 
@@ -461,7 +479,9 @@ const template = (titlePrefix, ...elements) => {
               renderForumLink(),
               renderFeedLink(),
               renderPixeliaLink(),
+              renderBankingLink(),
               renderMarketLink(),
+              renderProjectsLink(),
               renderJobsLink(),
               renderTransfersLink(),
               renderBookmarksLink(),
@@ -843,6 +863,7 @@ exports.authorView = ({
   lastPost,
   name,
   relationship,
+  ecoAddress
 }) => {
   const mention = `[@${name}](${feedId})`;
   const markdownMention = highlightJs.highlight(mention, { language: "markdown", ignoreIllegals: true }).value;
@@ -876,92 +897,99 @@ exports.authorView = ({
     }
   }
 
-const relationshipMessage = (() => {
-  if (relationship.me) return i18n.relationshipYou; 
-  const following = relationship.following === true;
-  const followsMe = relationship.followsMe === true;
-  if (following && followsMe) {
-    return i18n.relationshipMutuals;
-  }
-  const messages = [];
-  messages.push(
-    following
-      ? i18n.relationshipFollowing
-      : i18n.relationshipNone
-  );
-  messages.push(
-    followsMe
-      ? i18n.relationshipTheyFollow
-      : i18n.relationshipNotFollowing
-  );
-  return messages.join(". ") + ".";
-})();
-
-const prefix = section(
-  { class: "message" },
-  div(
-    { class: "profile" },
-    div({ class: "avatar-container" },
-      img({ class: "avatar", src: avatarUrl }),
-      h1({ class: "name" }, name),
-    ),
-    pre({
-      class: "md-mention",
-      innerHTML: markdownMention,
-    })
-  ),
-  description !== "" ? article({ innerHTML: markdown(description) }) : null,
-  footer(
-  div(
-    { class: "profile" },
-    ...contactForms.map(form => span({ style: "font-weight: bold;" }, form)),
-    relationship.me ? (
-      span({ class: "status you" }, i18n.relationshipYou)
-    ) : (
-      div({ class: "relationship-status" },
-        relationship.blocking && relationship.blockedBy
-          ? span({ class: "status blocked" }, i18n.relationshipMutualBlock)
-        : [
-            relationship.blocking
-              ? span({ class: "status blocked" }, i18n.relationshipBlocking)
-              : null,
-            relationship.blockedBy
-              ? span({ class: "status blocked-by" }, i18n.relationshipBlockedBy)
-              : null,
-            relationship.following && relationship.followsMe
-              ? span({ class: "status mutual" }, i18n.relationshipMutuals)
-              : [
-                  span(
-                    { class: "status supporting" },
-                    relationship.following
-                      ? i18n.relationshipFollowing
-                      : i18n.relationshipNone
-                  ),
-                  span(
-                    { class: "status supported-by" },
-                    relationship.followsMe
-                      ? i18n.relationshipTheyFollow
-                      : i18n.relationshipNotFollowing
-                  )
-               ]
-            ]
-        )
+  const relationshipMessage = (() => {
+    if (relationship.me) return i18n.relationshipYou;
+    const following = relationship.following === true;
+    const followsMe = relationship.followsMe === true;
+    if (following && followsMe) {
+      return i18n.relationshipMutuals;
+    }
+    const messages = [];
+    messages.push(
+      following
+        ? i18n.relationshipFollowing
+        : i18n.relationshipNone
+    );
+    messages.push(
+      followsMe
+        ? i18n.relationshipTheyFollow
+        : i18n.relationshipNotFollowing
+    );
+    return messages.join(". ") + ".";
+  })();
+
+  const prefix = section(
+    { class: "message" },
+    div(
+      { class: "profile" },
+      div({ class: "avatar-container" },
+        img({ class: "avatar", src: avatarUrl }),
+        h1({ class: "name" }, name),
+      ),
+      pre({
+        class: "md-mention",
+        innerHTML: markdownMention,
+      })
     ),
-    relationship.me
-      ? a({ href: `/profile/edit`, class: "btn" }, nbsp, i18n.editProfile)
-      : null,
-    a({ href: `/likes/${encodeURIComponent(feedId)}`, class: "btn" }, i18n.viewLikes),
-     !relationship.me
-        ? a(
-            { 
-              href: `/pm?recipients=${encodeURIComponent(feedId)}`, 
-              class: "btn" 
-            },
-            i18n.pmCreateButton
+    description !== "" ? article({ innerHTML: markdown(description) }) : null,
+    footer(
+      div(
+        { class: "profile" },
+        ...contactForms.map(form => span({ style: "font-weight: bold;" }, form)),
+        relationship.me ? (
+          span({ class: "status you" }, i18n.relationshipYou)
+        ) : (
+          div({ class: "relationship-status" },
+            relationship.blocking && relationship.blockedBy
+              ? span({ class: "status blocked" }, i18n.relationshipMutualBlock)
+            : [
+                relationship.blocking
+                  ? span({ class: "status blocked" }, i18n.relationshipBlocking)
+                  : null,
+                relationship.blockedBy
+                  ? span({ class: "status blocked-by" }, i18n.relationshipBlockedBy)
+                  : null,
+                relationship.following && relationship.followsMe
+                  ? span({ class: "status mutual" }, i18n.relationshipMutuals)
+                  : [
+                      span(
+                        { class: "status supporting" },
+                        relationship.following
+                          ? i18n.relationshipFollowing
+                          : i18n.relationshipNone
+                      ),
+                      span(
+                        { class: "status supported-by" },
+                        relationship.followsMe
+                          ? i18n.relationshipTheyFollow
+                          : i18n.relationshipNotFollowing
+                      )
+                   ]
+                ]
           )
-        : null
+        ),
+	ecoAddress
+	  ? div({ class: "eco-wallet" },
+              p(`${i18n.bankWalletConnected}: `, strong(ecoAddress))
+	    )
+	  : div({ class: "eco-wallet" },
+	      p(i18n.ecoWalletNotConfigured || "ECOin Wallet not configured")
+	    ),
+        relationship.me
+          ? a({ href: `/profile/edit`, class: "btn" }, nbsp, i18n.editProfile)
+          : null,
+        a({ href: `/likes/${encodeURIComponent(feedId)}`, class: "btn" }, i18n.viewLikes),
+        !relationship.me
+          ? a(
+              {
+                href: `/pm?recipients=${encodeURIComponent(feedId)}`,
+                class: "btn"
+              },
+              i18n.pmCreateButton
+            )
+          : null
       )
-   )
+    )
   );
 
   const linkUrl = relationship.me
@@ -1206,152 +1234,268 @@ exports.mentionsView = ({ messages, myFeedId }) => {
 };
 
 exports.privateView = async (messagesInput, filter) => {
-  const messages = Array.isArray(messagesInput) ? messagesInput : messagesInput.messages;
-  const userId = await getUserId();
-  const filtered =
-    filter === 'sent' ? messages.filter(m => m.value.content.from === userId) :
-    filter === 'inbox' ? messages.filter(m => m.value.content.to?.includes(userId)) :
-    messages;
-
-  function header({ sentAt, from, toLinks, botIcon = '', botLabel = '' }) {
-    return div({ class: 'pm-header' },
-      span({ class: 'date-link' }, `${moment(sentAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
-      botIcon || botLabel ? span({ class: 'pm-from' }, `${botIcon} ${botLabel}`) : null,
-      !botIcon && !botLabel
-        ? [
-            span({ class: 'pm-from' },
-              'From: ', a({ href: `/author/${encodeURIComponent(from)}`, class: 'user-link' }, from)
+    const messages = Array.isArray(messagesInput) ? messagesInput : messagesInput.messages;
+    const userId = await getUserId();
+
+    const filtered =
+        filter === 'sent' ? messages.filter(m => m.value.content.from === userId) :
+        filter === 'inbox' ? messages.filter(m => m.value.content.to?.includes(userId)) :
+        messages;
+
+    const linkAuthor = (id) =>
+        a({ class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, id);
+
+    const hrefFor = {
+        job: (id) => `/jobs/${encodeURIComponent(id)}`,
+        project: (id) => `/projects/${encodeURIComponent(id)}`,
+        market: (id) => `/market/${encodeURIComponent(id)}`
+    };
+
+    const clickableCardProps = (href, extraClass = '') => {
+        const props = { class: `pm-card ${extraClass}` };
+        if (href) {
+            props.onclick = `window.location='${href}'`;
+            props.tabindex = 0;
+            props.onkeypress = `if(event.key==='Enter') window.location='${href}'`;
+        }
+        return props;
+    };
+
+    const chip = (txt) => span({ class: 'chip' }, txt);
+
+    function header({ sentAt, from, toLinks, botIcon = '', botLabel = '' }) {
+        return div({ class: 'pm-header' },
+            span({ class: 'date-link' }, `${moment(sentAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
+            botIcon || botLabel
+                ? span({ class: 'pm-from' }, `${botIcon} ${botLabel}`)
+                : [
+                    span({ class: 'pm-from' }, i18n.pmFromLabel + ' ', linkAuthor(from)),
+                    span({ class: 'pm-to' }, i18n.pmToLabel + ' ', toLinks)
+                ]
+        );
+    }
+
+    function actions({ key, replyId }) {
+        const stop = { onclick: 'event.stopPropagation()' };
+        return div({ class: 'pm-actions' },
+            form({ method: 'POST', action: `/inbox/delete/${encodeURIComponent(key)}`, class: 'delete-message-form', style: 'display:inline-block;margin-right:8px;', ...stop },
+                button({ type: 'submit', class: 'delete-btn' }, i18n.privateDelete)
             ),
-            span({ class: 'pm-to' },
-              'To: ', toLinks
+            form({ method: 'GET', action: '/pm', style: 'display:inline-block;', ...stop },
+                input({ type: 'hidden', name: 'recipients', value: replyId }),
+                button({ type: 'submit', class: 'reply-btn' }, i18n.pmCreateButton)
             )
-          ] : null
-    );
-  }
+        );
+    }
 
-  function actions({ key, replyId }) {
-    return div({ class: 'pm-actions' },
-      form({ method: 'POST', action: `/inbox/delete/${encodeURIComponent(key)}`, class: 'delete-message-form', style: 'display:inline-block;margin-right:8px;' },
-        button({ type: 'submit', class: 'delete-btn' }, i18n.privateDelete)
-      ),
-      form({ method: 'GET', action: '/pm', style: 'display:inline-block;' },
-        input({ type: 'hidden', name: 'recipients', value: replyId }),
-        button({ type: 'submit', class: 'reply-btn' }, i18n.pmCreateButton || 'Write a PM')
-      )
-    );
-  }
-  
-  function clickableLinks(str) {
-    return str
-      .replace(/(@[a-zA-Z0-9/+._=-]+\.ed25519)/g,
-        (match, userId) =>
-          `<a class="user-link" href="/author/${encodeURIComponent(userId)}">${match}</a>`
-      )
-      .replace(/\/jobs\/([%a-zA-Z0-9/+._=-]+\.sha256)/g,
-        (match, jobId) =>
-          `<a class="job-link" href="/jobs/${jobId}">${match}</a>`
-      )
-      .replace(/\/market\/([%a-zA-Z0-9/+._=-]+\.sha256)/g,
-        (match, itemId) =>
-          `<a class="market-link" href="/market/${itemId}">${match}</a>`
-      );
-  }
+    function quoted(str) {
+        const m = str.match(/"([^"]+)"/);
+        return m ? m[1] : '';
+    }
 
-  return template(
-    i18n.private,
-    section(
-      div({ class: 'tags-header' },
-        h2(i18n.private),
-        p(i18n.privateDescription)
-      ),
-      div({ class: 'filters' },
-        form({ method: 'GET', action: '/inbox' }, [
-          button({
-            type: 'submit',
-            name: 'filter',
-            value: 'inbox',
-            class: filter === 'inbox' ? 'filter-btn active' : 'filter-btn'
-          }, i18n.privateInbox),
-          button({
-            type: 'submit',
-            name: 'filter',
-            value: 'sent',
-            class: filter === 'sent' ? 'filter-btn active' : 'filter-btn'
-          }, i18n.privateSent),
-          button({
-            type: 'submit',
-            name: 'filter',
-            value: 'create',
-            class: 'create-button',
-            formaction: '/pm',
-            formmethod: 'GET'
-          }, i18n.pmCreateButton)
-        ])
-      ),
-      div({ class: 'message-list' },
-        filtered.length
-          ? filtered.map(msg => {
-              const content = msg?.value?.content;
-              const author = msg?.value?.author;
-              if (!content || !author)
-                return div({ class: 'malformed-message' }, 'Invalid message');
-              const subject = content.subject || '(no subject)';
-              const text = content.text || '';
-              const sentAt = new Date(content.sentAt || msg.timestamp);
-              const from = content.from;
-              const toLinks = (content.to || []).map(addr =>
-                a({ class: 'user-link', href: `/author/${encodeURIComponent(addr)}` }, addr)
-              );
-              let jobMatch = text.match(/has subscribed to your job offer "([^"]+)"/);
-              let jobLinkMatch = text.match(/\/jobs\/([%a-zA-Z0-9/+._-]+\.sha256)/);
-              if (jobMatch && jobLinkMatch) {
-                const jobTitle = jobMatch[1];
-                const jobId = jobLinkMatch[1];
-                return div({ class: 'pm-card job-sub-notification' },
-                  header({ sentAt, from, toLinks, botIcon: '🟡', botLabel: '42-JobsBOT' }),
-                  h2({ class: 'pm-title', style: 'color:#ffe082;' }, 'New subscription to your job offer'),
-                  p(
-                    'Inhabitant with OASIS ID: ',
-                    a({ class: 'user-link', href: `/author/${encodeURIComponent(from)}` }, from),
-                    ' has subscribed to your job offer ',
-                    a({ class: "job-link", href: `/jobs/${encodeURIComponent(decodeURIComponent(jobId))}` }, `"${jobTitle}"`)
-                  ),
-                  actions({ key: msg.key, replyId: from })
-                );
-              }
-              let saleMatch = subject.match(/item "([^"]+)" has been sold/);
-              let buyerMatch = text.match(/OASIS ID: ([\w=/+.-]+)/);
-              let priceMatch = text.match(/for: \$([\d.]+)/);
-              let marketIdMatch = text.match(/\/market\/([%a-zA-Z0-9/+._-]+\.sha256)/);
-              if (saleMatch && buyerMatch && priceMatch && marketIdMatch) {
-                const itemTitle = saleMatch[1];
-                const buyerId = buyerMatch[1];
-                const price = priceMatch[1];
-                const marketId = marketIdMatch[1];
-                return div({ class: 'pm-card market-sold-notification' },
-                  header({ sentAt, from, toLinks, botIcon: '💰', botLabel: '42-MarketBOT' }),
-                  h2({ class: 'pm-title', style: 'color:#80cbc4;' }, 'Item Sold'),
-                  p(
-                    'Your item ',
-                    a({ class: 'market-link', href: `/market/${encodeURIComponent(decodeURIComponent(marketId))}` }, `"${itemTitle}"`),
-                    ' has been sold to ',
-                    a({ class: 'user-link', href: `/author/${encodeURIComponent(buyerId)}` }, buyerId),
-                    ` for $${price}.`
-                  ),
-                  actions({ key: msg.key, replyId: buyerId })
-                );
-              }
-              return div({ class: 'pm-card normal-pm' },
-                header({ sentAt, from, toLinks }),
-                h2(subject),
-                p({ class: 'message-text' }, ...renderUrl(text)),
-                actions({ key: msg.key, replyId: from })
-              );
-            })
-          : p({ class: 'empty' }, i18n.noPrivateMessages)
-      )
-    )
-  );
+    function pickLink(str, kind) {
+        if (kind === 'job') {
+            const m = str.match(/\/jobs\/([%A-Za-z0-9/+._=-]+\.sha256)/);
+            return m ? m[1] : '';
+        }
+        if (kind === 'project') {
+            const m = str.match(/\/projects\/([%A-Za-z0-9/+._=-]+\.sha256)/);
+            return m ? m[1] : '';
+        }
+        if (kind === 'market') {
+            const m = str.match(/\/market\/([%A-Za-z0-9/+._=-]+\.sha256)/);
+            return m ? m[1] : '';
+        }
+        return '';
+    }
+
+    function JobCard({ type, sentAt, from, toLinks, text, key }) {
+        const isSub = type === 'JOB_SUBSCRIBED';
+        const icon = isSub ? '🟡' : '🟠';
+        const titleH = isSub ? (i18n.inboxJobSubscribedTitle || 'New subscription to your job offer') : (i18n.inboxJobUnsubscribedTitle || 'Unsubscription from your job offer');
+        const jobTitle = quoted(text) || 'job';
+        const jobId = pickLink(text, 'job');
+        const href = jobId ? hrefFor.job(jobId) : null;
+        return div(
+            clickableCardProps(href, `job-notification ${isSub ? 'job-sub' : 'job-unsub'}`),
+            header({ sentAt, from, toLinks, botIcon: icon, botLabel: i18n.pmBotJobs }),
+            h2({ class: 'pm-title' }, titleH),
+            p(
+                i18n.pmInhabitantWithId, ' ',
+                linkAuthor(from), ' ',
+                isSub ? i18n.pmHasSubscribedToYourJobOffer : (i18n.pmHasUnsubscribedFromYourJobOffer || 'has unsubscribed from your job offer'),
+                ' ',
+                href ? a({ class: 'job-link', href }, `"${jobTitle}"`) : `"${jobTitle}"`
+            ),
+            actions({ key, replyId: from })
+        );
+    }
+
+    function ProjectFollowCard({ type, sentAt, from, toLinks, text, key }) {
+        const isFollow = type === 'PROJECT_FOLLOWED';
+        const icon = isFollow ? '🔔' : '🔕';
+        const titleH = isFollow
+            ? (i18n.inboxProjectFollowedTitle || 'New follower of your project')
+            : (i18n.inboxProjectUnfollowedTitle || 'Unfollowed your project');
+        const projectTitle = quoted(text) || 'project';
+        const projectId = pickLink(text, 'project');
+        const href = projectId ? hrefFor.project(projectId) : null;
+        return div(
+            clickableCardProps(href, `project-${isFollow ? 'follow' : 'unfollow'}-notification`),
+            header({ sentAt, from, toLinks, botIcon: icon, botLabel: i18n.pmBotProjects }),
+            h2({ class: 'pm-title' }, titleH),
+            p(
+                i18n.pmInhabitantWithId, ' ',
+                a({ class: 'user-link', href: `/author/${encodeURIComponent(from)}` }, from),
+                ' ',
+                isFollow ? (i18n.pmHasFollowedYourProject || 'has followed your project') : (i18n.pmHasUnfollowedYourProject || 'has unfollowed your project'),
+                ' ',
+                href ? a({ class: 'project-link', href }, `"${projectTitle}"`) : `"${projectTitle}"`
+            ),
+            actions({ key, replyId: from })
+        );
+    }
+
+    function MarketSoldCard({ sentAt, from, toLinks, subject, text, key }) {
+        const itemTitle = quoted(subject) || quoted(text) || 'item';
+        const buyerId = (text.match(/OASIS ID:\s*([\w=/+.-]+)/) || [])[1] || from;
+        const price = (text.match(/for:\s*\$([\d.]+)/) || [])[1] || '';
+        const marketId = pickLink(text, 'market');
+        const href = marketId ? hrefFor.market(marketId) : null;
+        return div(
+            clickableCardProps(href, 'market-sold-notification'),
+            header({ sentAt, from, toLinks, botIcon: '💰', botLabel: i18n.pmBotMarket }),
+            h2({ class: 'pm-title' }, i18n.inboxMarketItemSoldTitle),
+            p(
+                i18n.pmYourItem, ' ',
+                href ? a({ class: 'market-link', href }, `"${itemTitle}"`) : `"${itemTitle}"`,
+                ' ',
+                i18n.pmHasBeenSoldTo, ' ',
+                linkAuthor(buyerId),
+                price ? ` ${i18n.pmFor} $${price}.` : '.'
+            ),
+            actions({ key, replyId: buyerId })
+        );
+    }
+
+    function ProjectPledgeCard({ sentAt, from, toLinks, content, text, key }) {
+        const amount = content.meta?.amount ?? (text.match(/pledged\s+([\d.]+)/)?.[1] || '0');
+        const projectTitle = content.meta?.projectTitle ?? (text.match(/project\s+"([^"]+)"/)?.[1] || 'project');
+        const projectId = content.meta?.projectId ?? pickLink(text, 'project');
+        const href = projectId ? hrefFor.project(projectId) : null;
+        return div(
+            clickableCardProps(href, 'project-pledge-notification'),
+            header({ sentAt, from, toLinks, botIcon: '💚', botLabel: i18n.pmBotProjects }),
+            h2({ class: 'pm-title' }, i18n.inboxProjectPledgedTitle),
+            p(
+                i18n.pmInhabitantWithId, ' ',
+                linkAuthor(from), ' ',
+                i18n.pmHasPledged, ' ',
+                chip(`${amount} ECO`), ' ',
+                i18n.pmToYourProject, ' ',
+                href ? a({ class: 'project-link', href }, `"${projectTitle}"`) : `"${projectTitle}"`
+            ),
+            actions({ key, replyId: from })
+        );
+    }
+
+    function clickableLinks(str) {
+        return str
+            .replace(/(@[a-zA-Z0-9/+._=-]+\.ed25519)/g,
+                (match, id) => `<a class="user-link" href="/author/${encodeURIComponent(id)}">${match}</a>`
+            )
+            .replace(/\/jobs\/([%A-Za-z0-9/+._=-]+\.sha256)/g,
+                (match, id) => `<a class="job-link" href="${hrefFor.job(id)}">${match}</a>`
+            )
+            .replace(/\/projects\/([%A-Za-z0-9/+._=-]+\.sha256)/g,
+                (match, id) => `<a class="project-link" href="${hrefFor.project(id)}">${match}</a>`
+            )
+            .replace(/\/market\/([%A-Za-z0-9/+._=-]+\.sha256)/g,
+                (match, id) => `<a class="market-link" href="${hrefFor.market(id)}">${match}</a>`
+            );
+    }
+
+    return template(
+        i18n.private,
+        section(
+            div({ class: 'tags-header' },
+                h2(i18n.private),
+                p(i18n.privateDescription)
+            ),
+            div({ class: 'filters' },
+                form({ method: 'GET', action: '/inbox' }, [
+                    button({
+                        type: 'submit',
+                        name: 'filter',
+                        value: 'inbox',
+                        class: filter === 'inbox' ? 'filter-btn active' : 'filter-btn'
+                    }, i18n.privateInbox),
+                    button({
+                        type: 'submit',
+                        name: 'filter',
+                        value: 'sent',
+                        class: filter === 'sent' ? 'filter-btn active' : 'filter-btn'
+                    }, i18n.privateSent),
+                    button({
+                        type: 'submit',
+                        name: 'filter',
+                        value: 'create',
+                        class: 'create-button',
+                        formaction: '/pm',
+                        formmethod: 'GET'
+                    }, i18n.pmCreateButton)
+                ])
+            ),
+            div({ class: 'message-list' },
+                filtered.length
+                    ? filtered.map(msg => {
+                        const content = msg?.value?.content;
+                        const author = msg?.value?.author;
+                        if (!content || !author) return div({ class: 'pm-card malformed' }, i18n.pmInvalidMessage);
+                        const subjectRaw = content.subject || '';
+                        const subject = subjectRaw.toUpperCase();
+                        const text = content.text || '';
+                        const sentAt = new Date(content.sentAt || msg.timestamp);
+                        const from = content.from;
+                        const toLinks = (content.to || []).map(addr => linkAuthor(addr));
+
+                        if (subject === 'JOB_SUBSCRIBED' || subject === 'JOB_UNSUBSCRIBED') {
+                            return JobCard({ type: subject, sentAt, from, toLinks, text, key: msg.key });
+                        }
+			if (subject === 'PROJECT_FOLLOWED' || subject === 'PROJECT_UNFOLLOWED') {
+			    return ProjectFollowCard({ type: subject, sentAt, from, toLinks, text, key: msg.key });
+			}
+                        if (subject === 'MARKET_SOLD') {
+                            return MarketSoldCard({ sentAt, from, toLinks, subject: subjectRaw, text, key: msg.key });
+                        }
+                        if (subject === 'PROJECT_PLEDGE' || content.meta?.type === 'project-pledge') {
+                            return ProjectPledgeCard({ sentAt, from, toLinks, content, text, key: msg.key });
+                        }
+
+                        const jobTxt = text.match(/has subscribed to your job offer "([^"]+)"/);
+                        const jobIdLegacy = pickLink(text, 'job');
+                        if (jobTxt && jobIdLegacy) return JobCard({ type: 'JOB_SUBSCRIBED', sentAt, from, toLinks, text, key: msg.key });
+
+                        const projTxt = text.match(/has created a project "([^"]+)"/);
+                        const projIdLegacy = pickLink(text, 'project');
+                        if (projTxt && projIdLegacy) return ProjectCreatedCard({ sentAt, from, toLinks, text, key: msg.key });
+
+                        const saleTxt = subjectRaw.match(/item "([^"]+)" has been sold/) || text.match(/item "([^"]+)" has been sold/);
+                        const marketIdLegacy = pickLink(text, 'market');
+                        if (saleTxt && marketIdLegacy) return MarketSoldCard({ sentAt, from, toLinks, subject: subjectRaw, text, key: msg.key });
+
+                        return div(
+                            { class: 'pm-card normal-pm' },
+                            header({ sentAt, from, toLinks }),
+                            h2(content.subject || i18n.pmNoSubject),
+                            p({ class: 'message-text' }, ...renderUrl(clickableLinks(text))),
+                            actions({ key: msg.key, replyId: from })
+                        );
+                    })
+                    : p({ class: 'empty' }, i18n.noPrivateMessages)
+            )
+        )
+    );
 };
 
 exports.publishCustomView = async () => {

+ 2 - 0
src/views/modules_view.js

@@ -8,6 +8,7 @@ const modulesView = () => {
     { name: 'agenda', label: i18n.modulesAgendaLabel, description: i18n.modulesAgendaDescription },
     { name: 'ai', label: i18n.modulesAILabel, description: i18n.modulesAIDescription },
     { name: 'audios', label: i18n.modulesAudiosLabel, description: i18n.modulesAudiosDescription },
+    { name: 'banking', label: i18n.modulesBankingLabel, description: i18n.modulesBankingDescription },
     { name: 'bookmarks', label: i18n.modulesBookmarksLabel, description: i18n.modulesBookmarksDescription },
     { name: 'cipher', label: i18n.modulesCipherLabel, description: i18n.modulesCipherDescription },
     { name: 'docs', label: i18n.modulesDocsLabel, description: i18n.modulesDocsDescription },
@@ -24,6 +25,7 @@ const modulesView = () => {
     { name: 'multiverse', label: i18n.modulesMultiverseLabel, description: i18n.modulesMultiverseDescription },
     { name: 'opinions', label: i18n.modulesOpinionsLabel, description: i18n.modulesOpinionsDescription },
     { name: 'pixelia', label: i18n.modulesPixeliaLabel, description: i18n.modulesPixeliaDescription },
+    { name: 'projects', label: i18n.modulesProjectsLabel, description: i18n.modulesProjectsDescription },
     { name: 'popular', label: i18n.modulesPopularLabel, description: i18n.modulesPopularDescription },
     { name: 'reports', label: i18n.modulesReportsLabel, description: i18n.modulesReportsDescription },
     { name: 'summaries', label: i18n.modulesSummariesLabel, description: i18n.modulesSummariesDescription },

+ 539 - 0
src/views/projects_view.js

@@ -0,0 +1,539 @@
+const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, ul, li, table, thead, tbody, tr, th, td } = require("../server/node_modules/hyperaxe")
+const { template, i18n } = require('./main_views')
+const moment = require("../server/node_modules/moment")
+const { config } = require('../server/SSB_server.js')
+const { renderUrl } = require('../backend/renderUrl')
+
+const userId = config.keys.id
+
+const FILTERS = [
+  { key: 'ALL', i18n: 'projectFilterAll', title: 'projectAllTitle' },
+  { key: 'MINE', i18n: 'projectFilterMine', title: 'projectMineTitle' },
+  { key: 'ACTIVE', i18n: 'projectFilterActive', title: 'projectActiveTitle' },
+  { key: 'PAUSED', i18n: 'projectFilterPaused', title: 'projectPausedTitle' },
+  { key: 'COMPLETED', i18n: 'projectFilterCompleted', title: 'projectCompletedTitle' },
+  { key: 'FOLLOWING', i18n: 'projectFilterFollowing', title: 'projectFollowingTitle' },
+  { key: 'RECENT', i18n: 'projectFilterRecent', title: 'projectRecentTitle' },
+  { key: 'TOP', i18n: 'projectFilterTop', title: 'projectTopTitle' },
+  { key: 'BACKERS', i18n: 'projectFilterBackers', title: 'projectBackersLeaderboardTitle' }
+]
+
+const field = (labelText, value) =>
+  div({ class: 'card-field' },
+    span({ class: 'card-label' }, labelText),
+    span({ class: 'card-value' }, value)
+  )
+
+function sumAmounts(list = []) {
+  return list.reduce((s, x) => s + (parseFloat(x.amount || 0) || 0), 0)
+}
+
+function budgetSummary(project) {
+  const goal = parseFloat(project.goal || 0) || 0
+  const assigned = sumAmounts(project.bounties || [])
+  const remaining = Math.max(0, goal - assigned)
+  const exceeded = assigned > goal
+  return { goal, assigned, remaining, exceeded }
+}
+
+const followersCount = (p) => Array.isArray(p.followers) ? p.followers.length : 0
+const backersTotal = (p) => sumAmounts(p.backers || [])
+const backersCount = (p) => Array.isArray(p.backers) ? p.backers.length : 0
+
+function aggregateTopBackers(projects = []) {
+  const map = new Map()
+  for (const pr of projects) {
+    const backers = Array.isArray(pr.backers) ? pr.backers : []
+    for (const b of backers) {
+      const uid = b.userId
+      const amt = Math.max(0, parseFloat(b.amount || 0) || 0)
+      if (!map.has(uid)) map.set(uid, { userId: uid, total: 0, pledges: 0, projects: new Set() })
+      const rec = map.get(uid)
+      rec.total += amt
+      rec.pledges += 1
+      rec.projects.add(pr.id)
+    }
+  }
+  return Array.from(map.values())
+    .map(r => ({ ...r, projects: r.projects.size }))
+    .sort((a, b) => b.total - a.total)
+}
+
+function renderBackersLeaderboard(projects) {
+  const rows = aggregateTopBackers(projects)
+  if (!rows.length) return div({ class: 'backers-leaderboard empty' }, p(i18n.projectNoBackersFound))
+  return div({ class: 'backers-leaderboard' },
+    h2(i18n.projectBackersLeaderboardTitle),
+    ...rows.slice(0, 50).map(r =>
+      div({ class: 'backer-row' },
+        div({ class: 'card-field' },
+          span({ class: 'card-label' }, ''),
+          span({ class: 'card-value' },
+            a({ href: `/author/${encodeURIComponent(r.userId)}`, class: 'user-link user-pill' }, r.userId)
+          )
+        ),
+        div({ class: 'card-field' },
+          span({ class: 'card-label' }, i18n.projectBackerAmount + ':'),
+          span({ class: 'card-value' }, span({ class: 'chip chip-amt' }, `${r.total} ECO`))
+        ),
+        div({ class: 'card-field' },
+          span({ class: 'card-label' }, i18n.projectBackerPledges + ':'),
+          span({ class: 'card-value' }, span({ class: 'chip chip-pledges' }, String(r.pledges)))
+        ),
+        div({ class: 'card-field' },
+          span({ class: 'card-label' }, i18n.projectBackerProjects + ':'),
+          span({ class: 'card-value' }, span({ class: 'chip chip-projects' }, String(r.projects)))
+        )
+      )
+    )
+  )
+}
+
+function renderBackers(project) {
+  const backers = Array.isArray(project.backers) ? project.backers : [];
+  const total = sumAmounts(backers);
+  const mine = sumAmounts(backers.filter(b => b.userId === userId));
+
+  return div({ class: 'backers-block' },
+    h2(i18n.projectBackersTitle),
+    div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBackersTotal + ':'), span({ class: 'card-value' }, String(backers.length))),
+    div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBackersTotalPledged + ':'), span({ class: 'card-value' }, `${total} ECO`)),
+    mine > 0 ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBackersYourPledge + ':'), span({ class: 'card-value chip chip-you' }, `${mine} ECO`)) : null,
+    backers.length
+      ? table({ class: 'backers-table' },
+          thead(
+            tr(
+              th(i18n.projectBackerDate || 'Date'),
+              th(i18n.projectBackerAuthor || 'Author'),
+              th(i18n.projectBackerAmount)
+            )
+          ),
+          tbody(
+            ...backers.slice(0, 8).map(b =>
+              tr(
+                td(b.at ? moment(b.at).format('YYYY/MM/DD HH:mm') : ''),
+                td(a({ href: `/author/${encodeURIComponent(b.userId)}`, class: 'user-link' }, b.userId)),
+                td(`${b.amount} ECO`)
+              )
+            )
+          )
+        )
+      : p(i18n.projectBackersNone)
+  );
+}
+
+function renderPledgeBox(project, isAuthor) {
+  const isActive = String(project.status || 'ACTIVE').toUpperCase() === 'ACTIVE';
+  if (!isActive || isAuthor) return null;
+  return div({ class: 'pledge-box' },
+    h2(i18n.projectPledgeTitle),
+    form({ method: "POST", action: `/projects/pledge/${encodeURIComponent(project.id)}` },
+      input({ type: "number", name: "amount", min: "0.01", step: "0.01", required: true, placeholder: i18n.projectPledgePlaceholder }),
+      select({ name: "milestoneOrBounty" },
+        option({ value: "" }, i18n.projectSelectMilestoneOrBounty),
+        ...(project.milestones || []).map((m, idx) => option({ value: `milestone:${idx}` }, m.title)),
+        ...(project.bounties || []).map((b, idx) => option({ value: `bounty:${idx}` }, b.title))
+      ),
+      button({ class: "btn", type: "submit" }, i18n.projectPledgeButton)
+    )
+  );
+}
+
+function bountyTotalsForMilestone(project, mIndex) {
+  const list = (project.bounties || []).filter(b => (b.milestoneIndex ?? null) === mIndex)
+  const total = sumAmounts(list)
+  const done = list.filter(b => b.done).length
+  return { total, count: list.length, done }
+}
+
+function renderBudget(project) {
+  const S = budgetSummary(project)
+  return div({ class: `budget-summary${S.exceeded ? ' over' : ''}` },
+    div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBudgetGoal + ':'), span({ class: 'card-value' }, `${S.goal} ECO`)),
+    div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBudgetAssigned + ':'), span({ class: 'card-value' }, `${S.assigned} ECO`)),
+    div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBudgetRemaining + ':'), span({ class: 'card-value' }, `${S.remaining} ECO`)),
+    S.exceeded ? p({ class: 'warning' }, i18n.projectBudgetOver) : null
+  )
+}
+
+function renderFollowers(project) {
+  const followers = Array.isArray(project.followers) ? project.followers : []
+  if (!followers.length) return div({ class: 'followers-block' }, h2(i18n.projectFollowersTitle), p(i18n.projectFollowersNone))
+  const show = followers.slice(0, 12)
+  return div({ class: 'followers-block' },
+    h2(i18n.projectFollowersTitle),
+    ul(show.map(uid => li(a({ href: `/author/${encodeURIComponent(uid)}`, class: 'user-link' }, uid)))),
+    followers.length > show.length ? p(`+${followers.length - show.length} ${i18n.projectMore}`) : null
+  )
+}
+
+function renderMilestonesAndBounties(project, editable = false) {
+  const milestones = project.milestones || []
+  const bounties = project.bounties || []
+  const unassigned = bounties.filter(b => b.milestoneIndex == null)
+
+  const blocks = milestones.map((m, idx) => {
+    const { total, count, done } = bountyTotalsForMilestone(project, idx)
+    const items = bounties.filter(b => b.milestoneIndex === idx)
+    return div({ class: 'milestone-with-bounties' },
+      div({ class: 'milestone-stats' },
+          div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectMilestoneStatus + ':'), span({ class: 'card-value' }, m.done ? i18n.projectMilestoneDone.toUpperCase() : i18n.projectMilestoneOpen.toUpperCase())),
+          div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.projectBounties + ':'), span({ class: 'card-value' }, `${done}/${count} · ${total} ECO`))
+        ),
+      div({ class: 'milestone-head' },
+          span({ class: 'milestone-title' }, m.title),br(),br(),
+            span({ class: 'chip chip-pct' }, `${m.targetPercent || 0}%`),
+            m.dueDate ? span({ class: 'chip chip-due' }, `${i18n.projectMilestoneDue}: ${moment(m.dueDate).format('YYYY/MM/DD HH:mm')}`) : null,br(),
+            m.description ? p(...renderUrl(m.description)) : null,
+        (editable && !m.done) ? form({ method: 'POST', action: `/projects/milestones/complete/${encodeURIComponent(project.id)}/${idx}` },
+          button({ class: 'btn', type: 'submit' }, i18n.projectMilestoneMarkDone)
+        ) : null
+      ),
+      items.length
+        ? ul(items.map(b => {
+            const globalIndex = bounties.indexOf(b)
+            return li({ class: 'bounty-item' },
+              field(i18n.projectBountyStatus + ':', b.done ? i18n.projectBountyDone : (b.claimedBy ? i18n.projectBountyClaimed.toUpperCase() : i18n.projectBountyOpen.toUpperCase())),br(),
+              div({ class: 'bounty-main' },
+                span({ class: 'bounty-title' }, b.title),
+                span({ class: 'bounty-amount' }, `${b.amount} ECO`)
+              ),
+              b.description ? p(...renderUrl(b.description)) : null,
+              b.claimedBy ? field(i18n.projectBountyClaimedBy + ':', a({ href: `/author/${encodeURIComponent(b.claimedBy)}`, class: 'user-link' }, b.claimedBy)) : null,
+              (!editable && !b.done && !b.claimedBy && project.author !== userId)
+                ? form({ method: 'POST', action: `/projects/bounties/claim/${encodeURIComponent(project.id)}/${globalIndex}` },
+                    button({ type: 'submit', class: 'btn' }, i18n.projectBountyClaimButton)
+                  ) : null,
+              (editable && !b.done)
+                ? form({ method: 'POST', action: `/projects/bounties/complete/${encodeURIComponent(project.id)}/${globalIndex}` },
+                    button({ type: 'submit', class: 'btn' }, i18n.projectBountyCompleteButton)
+                  ) : null
+            )
+          }))
+        : p(i18n.projectNoBounties)
+    )
+  })
+
+  const unassignedBlock = unassigned.length
+    ? div({ class: 'bounty-milestone-block' },
+        h2(`${i18n.projectBounties} — ${i18n.projectMilestoneOpen} (no milestone)`),
+        ul(unassigned.map(b => {
+          const globalIndex = bounties.indexOf(b)
+          return li({ class: 'bounty-item' },
+            div({ class: 'bounty-main' },
+              span({ class: 'bounty-title' }, b.title),
+              span({ class: 'bounty-amount' }, `${b.amount} ECO`)
+            ),
+            b.description ? p(...renderUrl(b.description)) : null,
+            field(i18n.projectBountyStatus + ':', b.done ? i18n.projectBountyDone : (b.claimedBy ? i18n.projectBountyClaimed : i18n.projectBountyOpen)),
+            b.claimedBy ? field(i18n.projectBountyClaimedBy + ':', a({ href: `/author/${encodeURIComponent(b.claimedBy)}`, class: 'user-link' }, b.claimedBy)) : null,
+            (!editable && !b.done && !b.claimedBy && project.author !== userId)
+              ? form({ method: 'POST', action: `/projects/bounties/claim/${encodeURIComponent(project.id)}/${globalIndex}` },
+                  button({ type: 'submit', class: 'btn' }, i18n.projectBountyClaimButton)
+                ) : null,
+            (editable && !b.done)
+              ? form({ method: 'POST', action: `/projects/bounties/complete/${encodeURIComponent(project.id)}/${globalIndex}` },
+                  button({ type: 'submit', class: 'btn' }, i18n.projectBountyCompleteButton)
+                ) : null,
+            editable ? form({ method: 'POST', action: `/projects/bounties/update/${encodeURIComponent(project.id)}/${globalIndex}` },
+              label(i18n.projectMilestoneSelect), br(),
+              select({ name: 'milestoneIndex' },
+                option({ value: '', selected: b.milestoneIndex == null }, '-'),
+                ...(project.milestones || []).map((m, idx) =>
+                  option({ value: String(idx), selected: b.milestoneIndex === idx }, m.title)
+                )
+              ),
+              button({ class: 'btn', type: 'submit' }, i18n.projectBountyCreateButton)
+            ) : null
+          )
+        }))
+      )
+    : null
+
+  return div({ class: 'milestones-bounties' }, ...blocks, unassignedBlock)
+}
+
+const renderProjectList = (projects, filter) =>
+  projects.length > 0 ? projects.map(pr => {
+    const isMineFilter = String(filter).toUpperCase() === 'MINE'
+    const isAuthor = pr.author === userId
+    const statusUpper = String(pr.status || 'ACTIVE').toUpperCase()
+    const isActive = statusUpper === 'ACTIVE'
+    const pct = parseFloat(pr.progress || 0) || 0
+    const ratio = pr.goal ? Math.min(100, Math.round((parseFloat(pr.pledged || 0) / parseFloat(pr.goal)) * 100)) : 0
+    const mileDone = (pr.milestones || []).filter(m => m.done).length
+    const mileTotal = (pr.milestones || []).length
+    const statusClass = `status-${statusUpper.toLowerCase()}`
+    const remain = budgetSummary(pr).remaining
+    const followers = Array.isArray(pr.followers) ? pr.followers.length : 0
+    const backers = Array.isArray(pr.backers) ? pr.backers.length : 0
+
+    return div({ class: `project-card ${statusClass}` },
+      isMineFilter && isAuthor ? div({ class: "project-actions" },
+        form({ method: "GET", action: `/projects/edit/${encodeURIComponent(pr.id)}` },
+          button({ class: "update-btn", type: "submit" }, i18n.projectUpdateButton)
+        ),
+        form({ method: "POST", action: `/projects/delete/${encodeURIComponent(pr.id)}` },
+          button({ class: "delete-btn", type: "submit" }, i18n.projectDeleteButton)
+        ),
+        form({ method: "POST", action: `/projects/status/${encodeURIComponent(pr.id)}`, style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
+          select({ name: "status", onChange: "this.form.submit()" },
+            option({ value: "ACTIVE", selected: statusUpper === 'ACTIVE' }, i18n.projectStatusACTIVE),
+            option({ value: "PAUSED", selected: statusUpper === 'PAUSED' }, i18n.projectStatusPAUSED),
+            option({ value: "COMPLETED", selected: statusUpper === 'COMPLETED' }, i18n.projectStatusCOMPLETED),
+            option({ value: "CANCELLED", selected: statusUpper === 'CANCELLED' }, i18n.projectStatusCANCELLED)
+          ),
+          button({ class: "status-btn", type: "submit" }, i18n.projectSetStatus)
+        ),
+        form({ method: "POST", action: `/projects/progress/${encodeURIComponent(pr.id)}`, style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
+          input({ type: "number", name: "progress", min: "0", max: "100", value: pct }),
+          button({ class: "status-btn", type: "submit" }, i18n.projectSetProgress)
+        )
+      ) : null,
+
+      !isMineFilter && !isAuthor && isActive ? (Array.isArray(pr.followers) && pr.followers.includes(userId) ?
+        form({ method: "POST", action: `/projects/unfollow/${encodeURIComponent(pr.id)}` },
+          button({ type: "submit", class: "unsubscribe-btn" }, i18n.projectUnfollowButton)
+        ) :
+        form({ method: "POST", action: `/projects/follow/${encodeURIComponent(pr.id)}` },
+          button({ type: "submit", class: "subscribe-btn" }, i18n.projectFollowButton)
+        )
+      ) : null,
+
+      form({ method: "GET", action: `/projects/${encodeURIComponent(pr.id)}` },
+        button({ type: "submit", class: "filter-btn" }, i18n.viewDetailsButton)
+      ),
+      br(),
+      h2(pr.title),
+      pr.image ? div({ class: 'activity-image-preview' }, img({ src: `/blob/${encodeURIComponent(pr.image)}` })) : null,
+      field(i18n.projectDescription + ':', ''), p(...renderUrl(pr.description)),
+      field(i18n.projectStatus + ':', i18n['projectStatus' + statusUpper] || statusUpper),
+      field(i18n.projectProgress + ':', `${pct}%`),
+      field(i18n.projectGoal + ':'), br(),
+      div({ class: 'card-label' }, h2(`${pr.goal} ECO`)), br(),
+      field(i18n.projectPledged + ':', `${pr.pledged || 0} ECO`),
+      field(i18n.projectFunding + ':', `${ratio}%`),
+      field(i18n.projectMilestones + ':', `${mileDone}/${mileTotal}`),
+      field(i18n.projectFollowers + ':', String(followersCount(pr))),
+      field(i18n.projectBackers + ':', `${backersCount(pr)} · ${backersTotal(pr)} ECO`), br(),
+      isMineFilter && isAuthor ? [
+        renderBudget(pr),
+        renderMilestonesAndBounties(pr, true),
+        div({ class: 'new-milestone' },
+          h2(i18n.projectAddMilestoneTitle),
+          form({ method: 'POST', action: `/projects/milestones/add/${encodeURIComponent(pr.id)}` },
+            label(i18n.projectMilestoneTitle), br(),
+            input({ type: 'text', name: 'title', required: true }), br(), br(),
+            label(i18n.projectMilestoneDescription), br(),
+            textarea({ name: 'description', rows: '3' }), br(), br(),
+            label(i18n.projectMilestoneTargetPercent), br(),
+            input({ type: 'number', name: 'targetPercent', min: '0', max: '100', step: '1', value: '0' }), br(), br(),
+            label(i18n.projectMilestoneDueDate), br(),
+            input({ type: 'datetime-local', name: 'dueDate', min: moment().format("YYYY-MM-DDTHH:mm"), max: pr.deadline ? moment(pr.deadline).format("YYYY-MM-DDTHH:mm") : undefined }), br(), br(),
+            button({ class: 'btn', type: 'submit' }, i18n.projectMilestoneCreateButton)
+          )
+        ),
+        div({ class: 'new-bounty' },
+          h2(i18n.projectAddBountyTitle),
+          form({ method: "POST", action: `/projects/bounties/add/${encodeURIComponent(pr.id)}` },
+            label(i18n.projectBountyTitle), br(),
+            input({ type: "text", name: "title", required: true }), br(), br(),
+            label(i18n.projectBountyAmount), br(),
+            input({ type: "number", step: "0.01", name: "amount", required: true, max: String(budgetSummary(pr).remaining) }), br(), br(),
+            label(i18n.projectBountyDescription), br(),
+            textarea({ name: "description", rows: "3" }), br(), br(),
+            label(i18n.projectMilestoneSelect), br(),
+            select({ name: 'milestoneIndex' },
+              option({ value: '' }, '-'),
+              ...(pr.milestones || []).map((m, idx) =>
+                option({ value: String(idx) }, m.title)
+              )
+            ), br(), br(),
+            button({ class: 'btn', type: 'submit', disabled: remain <= 0 }, remain > 0 ? i18n.projectBountyCreateButton : 'No remaining budget')
+          )
+        )
+      ] : null,
+
+      div({ class: 'card-footer' },
+        span({ class: 'date-link' }, `${moment(pr.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
+        a({ href: `/author/${encodeURIComponent(pr.author)}`, class: 'user-link' }, pr.author)
+      )
+    )
+  }) : p(i18n.projectNoProjectsFound)
+
+const renderProjectForm = (project = {}, mode='create') => {
+  const isEdit = mode === 'edit'
+  const nowLocal = moment().format("YYYY-MM-DDTHH:mm")
+  const deadlineValue = project.deadline ? moment(project.deadline).format("YYYY-MM-DDTHH:mm") : ''
+  const milestoneMax = deadlineValue || undefined
+
+  return div({ class: "div-center project-form" },
+    form({
+      action: isEdit ? `/projects/update/${encodeURIComponent(project.id)}` : "/projects/create",
+      method: "POST",
+      enctype: "multipart/form-data"
+    },
+      label(i18n.projectTitle), br(),
+      input({ type: "text", name: "title", required: true, placeholder: i18n.projectTitlePlaceholder, value: project.title || "" }), br(), br(),
+      label(i18n.projectDescription), br(),
+      textarea({ name: "description", rows: "6", required: true, placeholder: i18n.projectDescriptionPlaceholder }, project.description || ""), br(), br(),
+      label(i18n.projectImage), br(),
+      input({ type: "file", name: "image", accept: "image/*" }), br(),
+      project.image ? img({ src: `/blob/${encodeURIComponent(project.image)}`, class: 'existing-image' }) : null, br(),
+      label(i18n.projectGoal), br(),
+      input({ type: "number", step: "0.01", min: "0.01", name: "goal", required: true, placeholder: i18n.projectGoalPlaceholder, value: project.goal || "" }), br(), br(),
+      label(i18n.projectDeadline), br(),
+      input({ type: "datetime-local", name: "deadline", id: "deadline", required: true, min: nowLocal, value: deadlineValue }), br(), br(),
+      h2(i18n.projectAddMilestoneTitle),
+      label(i18n.projectMilestoneTitle), br(),
+      input({ type: "text", name: "milestoneTitle", required: true, placeholder: i18n.projectMilestoneTitlePlaceholder }), br(), br(),
+      label(i18n.projectMilestoneDescription), br(),
+      textarea({ name: "milestoneDescription", rows: "3", placeholder: i18n.projectMilestoneDescriptionPlaceholder }), br(), br(),
+      label(i18n.projectMilestoneTargetPercent), br(),
+      input({ type: "number", name: "milestoneTargetPercent", min: "0", max: "100", step: "1", value: "0" }), br(), br(),
+      label(i18n.projectMilestoneDueDate), br(),
+      input({ type: "datetime-local", name: "milestoneDueDate", min: nowLocal, max: milestoneMax }), br(), br(),
+      button({ type: "submit" }, isEdit ? i18n.projectUpdateButton : i18n.projectCreateButton)
+    )
+  )
+}
+
+exports.projectsView = async (projectsOrForm, filter="ALL") => {
+  const filterObj = FILTERS.find(f => f.key === filter) || FILTERS[0]
+  const sectionTitle = i18n[filterObj.title] || i18n.projectAllTitle
+  return template(
+    i18n.projectsTitle,
+    section(
+      div({ class: "tags-header" }, h2(sectionTitle), p(i18n.projectsDescription)),
+      div({ class: "filters" },
+        form({ method: "GET", action: "/projects", style: "display:flex;gap:12px;" },
+          FILTERS.map(f =>
+            button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, i18n[f.i18n])
+          ).concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.projectCreateProject))
+        )
+      ),
+      filter === 'CREATE' || filter === 'EDIT'
+        ? (() => {
+            const prToEdit = filter === 'EDIT' ? projectsOrForm[0] : {}
+            return renderProjectForm(prToEdit, filter === 'EDIT' ? 'edit' : 'create')
+          })()
+        : (filter === 'BACKERS'
+            ? renderBackersLeaderboard(projectsOrForm)
+            : div({ class: "projects-list" }, renderProjectList(projectsOrForm, filter))
+          )
+    )
+  )
+}
+
+exports.singleProjectView = async (project, filter="ALL") => {
+  const isAuthor = project.author === userId
+  const statusUpper = String(project.status || 'ACTIVE').toUpperCase()
+  const isActive = statusUpper === 'ACTIVE'
+  const statusClass = `status-${statusUpper.toLowerCase()}`
+  const ratio = project.goal ? Math.min(100, Math.round((parseFloat(project.pledged || 0) / parseFloat(project.goal)) * 100)) : 0
+  const remain = budgetSummary(project).remaining
+
+  return template(
+    i18n.projectsTitle,
+    section(
+      div({ class: "tags-header" }, h2(i18n.projectsTitle), p(i18n.projectsDescription)),
+      div({ class: "filters" },
+        form({ method: "GET", action: "/projects", style: "display:flex;gap:12px;" },
+          FILTERS.map(f =>
+            button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, i18n[f.i18n])
+          ).concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.projectCreateProject))
+        )
+      ),
+      div({ class: `project-card ${statusClass}` },
+        isAuthor ? div({ class: "project-actions" },
+          form({ method: "GET", action: `/projects/edit/${encodeURIComponent(project.id)}` },
+            button({ class: "update-btn", type: "submit" }, i18n.projectUpdateButton)
+          ),
+          form({ method: "POST", action: `/projects/delete/${encodeURIComponent(project.id)}` },
+            button({ class: "delete-btn", type: "submit" }, i18n.projectDeleteButton)
+          ),
+          form({ method: "POST", action: `/projects/status/${encodeURIComponent(project.id)}`, style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
+            select({ name: "status" },
+              option({ value: "ACTIVE", selected: statusUpper === 'ACTIVE' }, i18n.projectStatusACTIVE),
+              option({ value: "PAUSED", selected: statusUpper === 'PAUSED' }, i18n.projectStatusPAUSED),
+              option({ value: "COMPLETED", selected: statusUpper === 'COMPLETED' }, i18n.projectStatusCOMPLETED),
+              option({ value: "CANCELLED", selected: statusUpper === 'CANCELLED' }, i18n.projectStatusCANCELLED)
+            ),
+            button({ class: "status-btn", type: "submit" }, i18n.projectSetStatus)
+          ),
+          form({ method: "POST", action: `/projects/progress/${encodeURIComponent(project.id)}`, style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
+            input({ type: "number", name: "progress", min: "0", max: "100", value: project.progress || 0 }),
+            button({ class: "status-btn", type: "submit" }, i18n.projectSetProgress)
+          )
+        ) : null,
+        (!isAuthor && Array.isArray(project.followers) && project.followers.includes(userId))
+          ? p({ class: 'hint' }, i18n.projectYouFollowHint) : null,
+        h2(project.title),
+        project.image ? div({ class: 'activity-image-preview' }, img({ src: `/blob/${encodeURIComponent(project.image)}` })) : null,
+        field(i18n.projectDescription + ':', ''), p(...renderUrl(project.description)),
+        field(i18n.projectStatus + ':', i18n['projectStatus' + statusUpper] || statusUpper),
+        field(i18n.projectGoal + ':'), br(),
+        div({ class: 'card-label' }, h2(`${project.goal} ECO`)), br(),
+	field(i18n.projectPledged + ':', `${project.pledged || 0} ECO`),
+	field(i18n.projectFunding + ':', `${ratio}%`),
+        field(i18n.projectProgress + ':', `${project.progress || 0}%`), br(),
+        div({ class: 'social-stats' },
+          field(i18n.projectFollowers + ':', String(followersCount(project))),
+          field(i18n.projectBackers + ':', `${backersCount(project)} · ${backersTotal(project)} ECO`)
+        ),
+        renderBudget(project),
+        renderMilestonesAndBounties(project, isAuthor),
+        renderFollowers(project, isAuthor),
+        (!isAuthor && isActive) ? (Array.isArray(project.followers) && project.followers.includes(userId) ?
+          form({ method: "POST", action: `/projects/unfollow/${encodeURIComponent(project.id)}` },
+            button({ class: "filter-btn", type: "submit" }, i18n.projectUnfollowButton)
+          ) :
+          form({ method: "POST", action: `/projects/follow/${encodeURIComponent(project.id)}` },
+            button({ class: "filter-btn", type: "submit" }, i18n.projectFollowButton)
+          )
+        ) : null,
+        br(),
+        renderBackers(project),
+        renderPledgeBox(project, isAuthor), 
+        isAuthor ? div({ class: 'new-milestone' },
+          h2(i18n.projectAddMilestoneTitle),
+          form({ method: 'POST', action: `/projects/milestones/add/${encodeURIComponent(project.id)}` },
+            label(i18n.projectMilestoneTitle), br(),
+            input({ type: 'text', name: 'title', required: true }), br(), br(),
+            label(i18n.projectMilestoneDescription), br(),
+            textarea({ name: 'description', rows: '3' }), br(), br(),
+            label(i18n.projectMilestoneTargetPercent), br(),
+            input({ type: 'number', name: 'targetPercent', min: '0', max: '100', step: '1', value: '0' }), br(), br(),
+            label(i18n.projectMilestoneDueDate), br(),
+            input({ type: 'datetime-local', name: 'dueDate', min: moment().format("YYYY-MM-DDTHH:mm"), max: project.deadline ? moment(project.deadline).format("YYYY-MM-DDTHH:mm") : undefined }), br(), br(),
+            button({ class: 'btn', type: 'submit' }, i18n.projectMilestoneCreateButton)
+          )
+        ) : null,
+        isAuthor ? div({ class: 'new-bounty' },
+          h2(i18n.projectAddBountyTitle),
+          form({ method: "POST", action: `/projects/bounties/add/${encodeURIComponent(project.id)}` },
+            label(i18n.projectBountyTitle), br(),
+            input({ type: "text", name: "title", required: true }), br(), br(),
+            label(i18n.projectBountyAmount), br(),
+            input({ type: "number", step: "0.01", name: "amount", required: true, max: String(budgetSummary(project).remaining) }), br(), br(),
+            label(i18n.projectBountyDescription), br(),
+            textarea({ name: "description", rows: "3" }), br(), br(),
+            label(i18n.projectMilestoneSelect), br(),
+            select({ name: 'milestoneIndex' },
+              option({ value: '' }, '-'),
+              ...(project.milestones || []).map((m, idx) =>
+                option({ value: String(idx) }, m.title)
+              )
+            ), br(), br(),
+            button({ class: 'btn', type: 'submit', disabled: remain <= 0 }, remain > 0 ? i18n.projectBountyCreateButton : i18n.projectNoRemainingBudget)
+          )
+        ) : null,
+        div({ class: 'card-footer' },
+          span({ class: 'date-link' }, `${moment(project.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed} `),
+          a({ href: `/author/${encodeURIComponent(project.author)}`, class: 'user-link' }, project.author)
+        )
+      )
+    )
+  )
+}
+

+ 35 - 0
src/views/settings_view.js

@@ -25,6 +25,9 @@ const settingsView = ({ version, aiPrompt }) => {
   const walletUrl = currentConfig.wallet.url;
   const walletUser = currentConfig.wallet.user;
   const walletFee = currentConfig.wallet.feee;
+  const pubWalletUrl = currentConfig.walletPub.url || '';
+  const pubWalletUser = currentConfig.walletPub.user || '';
+  const pubWalletPass = currentConfig.walletPub.pass || '';
 
   const themeElements = [
     option({ value: "Dark-SNH", selected: theme === "Dark-SNH" ? true : undefined }, "Dark-SNH"),
@@ -113,6 +116,38 @@ const settingsView = ({ version, aiPrompt }) => {
         )
       )
     ),
+    section(
+      div({ class: "tags-header" },
+        h2(i18n.pubWallet),
+        p(i18n.pubWalletDescription),
+        form(
+          { action: "/settings/pub-wallet", method: "POST" },
+          label({ for: "pub_wallet_url" }, i18n.walletAddress), br(),
+          input({
+            type: "text",
+            id: "pub_wallet_url",
+            name: "wallet_url",
+            placeholder: pubWalletUrl,
+            value: pubWalletUrl
+          }), br(),
+          label({ for: "pub_wallet_user" }, i18n.walletUser), br(),
+          input({
+            type: "text",
+            id: "pub_wallet_user",
+            name: "wallet_user",
+            placeholder: pubWalletUser,
+            value: pubWalletUser
+          }), br(),
+          label({ for: "pub_wallet_pass" }, i18n.walletPass), br(),
+          input({
+            type: "password",
+            id: "pub_wallet_pass",
+            name: "wallet_pass"
+          }), br(),
+          button({ type: "submit" }, i18n.pubWalletConfiguration)
+        )
+      )
+    ),
     section(
       div({ class: "tags-header" },
         h2(i18n.aiTitle),

+ 184 - 87
src/views/stats_view.js

@@ -1,17 +1,22 @@
-const { div, h2, p, section, button, form, input, ul, li, a, h3, span, strong } = require("../server/node_modules/hyperaxe");
+const { div, h2, p, section, button, form, input, ul, li, a, h3, span, strong, table, tr, td, th } = require("../server/node_modules/hyperaxe");
 const { template, i18n } = require('./main_views');
 
+const C = (stats, t) => Number((stats && stats.content && stats.content[t]) || 0);
+const O = (stats, t) => Number((stats && stats.opinions && stats.opinions[t]) || 0);
+
 exports.statsView = (stats, filter) => {
   const title = i18n.statsTitle;
   const description = i18n.statsDescription;
   const modes = ['ALL', 'MINE', 'TOMBSTONE'];
   const types = [
-    'bookmark', 'event', 'task', 'votes', 'report', 'feed',
-    'image', 'audio', 'video', 'document', 'transfer', 'post', 'tribe', 'market', 'forum', 'job'
+    'bookmark', 'event', 'task', 'votes', 'report', 'feed', 'project',
+    'image', 'audio', 'video', 'document', 'transfer', 'post', 'tribe',
+    'market', 'forum', 'job', 'aiExchange'
   ];
-  const totalContent = types.reduce((sum, t) => sum + (stats.content[t] || 0), 0);
-  const totalOpinions = types.reduce((sum, t) => sum + (stats.opinions[t] || 0), 0);
+  const totalContent = types.reduce((sum, t) => sum + C(stats, t), 0);
+  const totalOpinions = types.reduce((sum, t) => sum + O(stats, t), 0);
   const blockStyle = 'padding:16px;border:1px solid #ddd;border-radius:8px;margin-bottom:24px;';
+  const headerStyle = 'background-color:#f8f9fa; padding:24px; border-radius:8px; border:1px solid #e0e0e0; box-shadow:0 2px 8px rgba(0,0,0,0.1);';
 
   return template(
     title,
@@ -20,6 +25,7 @@ exports.statsView = (stats, filter) => {
         h2(title),
         p(description)
       ),
+
       div({ class: 'mode-buttons', style: 'display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:16px;margin-bottom:24px;' },
         modes.map(m =>
           form({ method: 'GET', action: '/stats' },
@@ -28,108 +34,199 @@ exports.statsView = (stats, filter) => {
           )
         )
       ),
+
       section(
-	div({ style: 'background-color:#f8f9fa; padding:24px; border-radius:8px; border:1px solid #e0e0e0; box-shadow:0 2px 8px rgba(0,0,0,0.1);' },
-	 h3({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsCreatedAt}: `, span({ style: 'color:#888;' }, stats.createdAt)),
-	  h3({ style: 'font-size:18px; color:#555; margin:8px 0; font-weight:600;' }, a({ class: "user-link", href: `/author/${encodeURIComponent(stats.id)}`, style: 'color:#007bff; text-decoration:none;' }, stats.id)),
-	  div({ style: 'margin-bottom:16px;' },
-	  ul({ style: 'list-style-type:none; padding:0; margin:0;' },
-	    li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
-	      `${i18n.statsBlobsSize}: `,
-	      span({ style: 'color:#888;' }, stats.statsBlobsSize)
-	    ),
-	    li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
-	      `${i18n.statsBlockchainSize}: `,
-	      span({ style: 'color:#888;' }, stats.statsBlockchainSize)
-	    ),
-	    li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
-	      strong(`${i18n.statsSize}: `,
-	      span({ style: 'color:#888;' },
-	      span({ style: 'color:#555;' }, stats.folderSize) 
-	      )
-	      )
-	    )
-	   )
-	  )
-	),
+        div({ style: headerStyle },
+          h3({ style: 'font-size:18px; color:#555; margin:8px 0;' }, `${i18n.statsCreatedAt}: `, span({ style: 'color:#888;' }, stats.createdAt)),
+          h3({ style: 'font-size:18px; color:#555; margin:8px 0; font-weight:600;' },
+            a({ class: "user-link", href: `/author/${encodeURIComponent(stats.id)}`, style: 'color:#007bff; text-decoration:none;' }, stats.id)
+          ),
+          div({ style: 'margin-bottom:16px;' },
+            ul({ style: 'list-style-type:none; padding:0; margin:0;' },
+              li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
+                `${i18n.statsBlobsSize}: `,
+                span({ style: 'color:#888;' }, stats.statsBlobsSize)
+              ),
+              li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
+                `${i18n.statsBlockchainSize}: `,
+                span({ style: 'color:#888;' }, stats.statsBlockchainSize)
+              ),
+              li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
+                strong(`${i18n.statsSize}: `, span({ style: 'color:#888;' }, span({ style: 'color:#555;' }, stats.folderSize)))
+              )
+            )
+          )
+        ),
+
+        div({ style: headerStyle },
+          h3({ style: 'font-size:18px; color:#555; margin:8px 0; font-weight:600;' }, i18n.statsBankingTitle),
+          ul({ style: 'list-style-type:none; padding:0; margin:0;' },
+		li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
+		  `${i18n.statsEcoWalletLabel}: `,
+		  a(
+		    { 
+		      href: '/wallet',
+		      style: 'color:#007bff; text-decoration:none; word-break:break-all;' 
+		    },
+		    stats?.banking?.myAddress || i18n.statsEcoWalletNotConfigured
+		  )
+		),
+            li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
+              `${i18n.statsTotalEcoAddresses}: `,
+              span({ style: 'color:#888;' }, String(stats?.banking?.totalAddresses || 0))
+            )
+          )
+        ),
+
+        div({ style: headerStyle },
+          h3({ style: 'font-size:18px; color:#555; margin:8px 0; font-weight:600;' }, i18n.statsAITraining),
+          ul({ style: 'list-style-type:none; padding:0; margin:0;' },
+            li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
+              `${i18n.statsAIExchanges}: `,
+              span({ style: 'color:#888;' }, String(C(stats, 'aiExchange') || 0))
+            )
+          )
+        ),
+
         filter === 'ALL'
           ? div({ class: 'stats-container' }, [
-            div({ style: blockStyle },
-              h2(`${i18n.statsTotalInhabitants}: ${stats.inhabitants}`)
-            ),
-            div({ style: blockStyle },
-              h2(`${i18n.statsDiscoveredTribes}: ${stats.content.tribe}`)
-            ),
-            div({ style: blockStyle },
-              h2(`${i18n.statsDiscoveredMarket}: ${stats.content.market}`)
-            ),
-            div({ style: blockStyle },
-              h2(`${i18n.statsDiscoveredJob}: ${stats.content.job}`)
-            ),
-            div({ style: blockStyle },
-              h2(`${i18n.statsDiscoveredTransfer}: ${stats.content.transfer}`)
-            ),
-            div({ style: blockStyle },
-              h2(`${i18n.statsDiscoveredForum}: ${stats.content.forum}`)
-            ),
-            div({ style: blockStyle },
-              h2(`${i18n.statsNetworkOpinions}: ${totalOpinions}`),
-              ul(types.map(t =>
-                stats.opinions[t] > 0
-                  ? li(`${i18n[`stats${t.charAt(0).toUpperCase() + t.slice(1)}`]}: ${stats.opinions[t]}`)
-                  : null
-              ).filter(Boolean))
-            ),
-            div({ style: blockStyle },
-              h2(`${i18n.statsNetworkContent}: ${totalContent}`),
-              ul(types.map(t =>
-                stats.content[t] > 0
-                  ? li(`${i18n[`stats${t.charAt(0).toUpperCase() + t.slice(1)}`]}: ${stats.content[t]}`)
-                  : null
-              ).filter(Boolean))
-            )
-          ])
-          : filter === 'MINE'
-            ? div({ class: 'stats-container' }, [
               div({ style: blockStyle },
-                h2(`${i18n.statsDiscoveredTribes}: ${stats.memberTribes.length}`),
-                ul(stats.memberTribes.map(name => li(name)))
+                h2(i18n.statsActivity7d),
+                table({ style: 'width:100%; border-collapse: collapse;' },
+                  tr(th(i18n.day), th(i18n.messages)),
+                  ...(Array.isArray(stats.activity?.daily7) ? stats.activity.daily7 : []).map(row =>
+                    tr(td(row.day), td(String(row.count)))
+                  )
+                ),
+                p(`${i18n.statsActivity7dTotal}: ${stats.activity?.daily7Total || 0}`),
+                p(`${i18n.statsActivity30dTotal}: ${stats.activity?.daily30Total || 0}`)
               ),
               div({ style: blockStyle },
-                h2(`${i18n.statsYourMarket}: ${stats.content.market}`)
+                h2(`${i18n.statsDiscoveredTribes}: ${stats.memberTribes.length}`),
+                table({ style: 'width:100%; border-collapse: collapse; margin-top: 8px;' },
+                  tr(th(i18n.typeTribe || 'Tribe')),
+                  ...stats.memberTribes.map(name => tr(td(name)))
+                )
               ),
+              div({ style: blockStyle }, h2(`${i18n.statsUsersTitle}: ${stats.usersKPIs?.totalInhabitants || stats.inhabitants || 0}`)),
+              div({ style: blockStyle }, h2(`${i18n.statsDiscoveredForum}: ${C(stats, 'forum')}`)),
+              div({ style: blockStyle }, h2(`${i18n.statsDiscoveredTransfer}: ${C(stats, 'transfer')}`)),
               div({ style: blockStyle },
-                h2(`${i18n.statsYourJob}: ${stats.content.job}`)
+                h2(i18n.statsMarketTitle),
+                ul([
+                  li(`${i18n.statsMarketTotal}: ${stats.marketKPIs?.total || 0}`),
+                  li(`${i18n.statsMarketForSale}: ${stats.marketKPIs?.forSale || 0}`),
+                  li(`${i18n.statsMarketReserved}: ${stats.marketKPIs?.reserved || 0}`),
+                  li(`${i18n.statsMarketClosed}: ${stats.marketKPIs?.closed || 0}`),
+                  li(`${i18n.statsMarketSold}: ${stats.marketKPIs?.sold || 0}`),
+                  li(`${i18n.statsMarketRevenue}: ${((stats.marketKPIs?.revenueECO || 0)).toFixed(6)} ECO`),
+                  li(`${i18n.statsMarketAvgSoldPrice}: ${((stats.marketKPIs?.avgSoldPrice || 0)).toFixed(6)} ECO`)
+                ])
               ),
               div({ style: blockStyle },
-                h2(`${i18n.statsYourTransfer}: ${stats.content.transfer}`)
+                h2(i18n.statsProjectsTitle),
+                ul([
+                  li(`${i18n.statsProjectsTotal}: ${stats.projectsKPIs?.total || 0}`),
+                  li(`${i18n.statsProjectsActive}: ${stats.projectsKPIs?.active || 0}`),
+                  li(`${i18n.statsProjectsCompleted}: ${stats.projectsKPIs?.completed || 0}`),
+                  li(`${i18n.statsProjectsPaused}: ${stats.projectsKPIs?.paused || 0}`),
+                  li(`${i18n.statsProjectsCancelled}: ${stats.projectsKPIs?.cancelled || 0}`),
+                  li(`${i18n.statsProjectsGoalTotal}: ${(stats.projectsKPIs?.ecoGoalTotal || 0)} ECO`),
+                  li(`${i18n.statsProjectsPledgedTotal}: ${(stats.projectsKPIs?.ecoPledgedTotal || 0)} ECO`),
+                  li(`${i18n.statsProjectsSuccessRate}: ${((stats.projectsKPIs?.successRate || 0)).toFixed(1)}%`),
+                  li(`${i18n.statsProjectsAvgProgress}: ${((stats.projectsKPIs?.avgProgress || 0)).toFixed(1)}%`),
+                  li(`${i18n.statsProjectsMedianProgress}: ${((stats.projectsKPIs?.medianProgress || 0)).toFixed(1)}%`),
+                  li(`${i18n.statsProjectsActiveFundingAvg}: ${((stats.projectsKPIs?.activeFundingAvg || 0)).toFixed(1)}%`)
+                ])
               ),
               div({ style: blockStyle },
-                h2(`${i18n.statsYourForum}: ${stats.content.forum}`)
+                h2(i18n.statsJobsTitle),
+                ul([
+                  li(`${i18n.statsJobsTotal}: ${stats.jobsKPIs?.total || 0}`),
+                  li(`${i18n.statsJobsOpen}: ${stats.jobsKPIs?.open || 0}`),
+                  li(`${i18n.statsJobsClosed}: ${stats.jobsKPIs?.closed || 0}`),
+                  li(`${i18n.statsJobsOpenVacants}: ${stats.jobsKPIs?.openVacants || 0}`),
+                  li(`${i18n.statsJobsSubscribersTotal}: ${stats.jobsKPIs?.subscribersTotal || 0}`),
+                  li(`${i18n.statsJobsAvgSalary}: ${((stats.jobsKPIs?.avgSalary || 0)).toFixed(2)} ECO`),
+                  li(`${i18n.statsJobsMedianSalary}: ${((stats.jobsKPIs?.medianSalary || 0)).toFixed(2)} ECO`)
+                ])
               ),
               div({ style: blockStyle },
-                h2(`${i18n.statsYourOpinions}: ${totalOpinions}`),
-                ul(types.map(t =>
-                  stats.opinions[t] > 0
-                    ? li(`${i18n[`stats${t.charAt(0).toUpperCase() + t.slice(1)}`]}: ${stats.opinions[t]}`)
-                    : null
-                ).filter(Boolean))
+                h2(`${i18n.statsNetworkOpinions}: ${totalOpinions}`),
+                ul(types.map(t => O(stats, t) > 0 ? li(`${i18n[`stats${t.charAt(0).toUpperCase() + t.slice(1)}`]}: ${O(stats, t)}`) : null).filter(Boolean))
               ),
               div({ style: blockStyle },
-                h2(`${i18n.statsYourContent}: ${totalContent}`),
-                ul(types.map(t =>
-                  stats.content[t] > 0
-                    ? li(`${i18n[`stats${t.charAt(0).toUpperCase() + t.slice(1)}`]}: ${stats.content[t]}`)
-                    : null
-                ).filter(Boolean))
+                h2(`${i18n.statsNetworkContent}: ${totalContent}`),
+                ul(types.map(t => C(stats, t) > 0 ? li(`${i18n[`stats${t.charAt(0).toUpperCase() + t.slice(1)}`]}: ${C(stats, t)}`) : null).filter(Boolean))
               )
             ])
+          : filter === 'MINE'
+            ? div({ class: 'stats-container' }, [
+                div({ style: blockStyle },
+                  h2(i18n.statsActivity7d),
+                  table({ style: 'width:100%; border-collapse: collapse;' },
+                    tr(th(i18n.day), th(i18n.messages)),
+                    ...(Array.isArray(stats.activity?.daily7) ? stats.activity.daily7 : []).map(row =>
+                      tr(td(row.day), td(String(row.count)))
+                    )
+                  ),
+                  p(`${i18n.statsActivity7dTotal}: ${stats.activity?.daily7Total || 0}`),
+                  p(`${i18n.statsActivity30dTotal}: ${stats.activity?.daily30Total || 0}`)
+                ),
+                div({ style: blockStyle },
+                  h2(`${i18n.statsDiscoveredTribes}: ${stats.memberTribes.length}`),
+                  table({ style: 'width:100%; border-collapse: collapse; margin-top: 8px;' },
+                    tr(th(i18n.typeTribe || 'Tribe')),
+                    ...stats.memberTribes.map(name => tr(td(name)))
+                  )
+                ),
+                div({ style: blockStyle }, h2(`${i18n.statsYourForum}: ${C(stats, 'forum')}`)),
+                div({ style: blockStyle }, h2(`${i18n.statsYourTransfer}: ${C(stats, 'transfer')}`)),
+                div({ style: blockStyle },
+                  h2(i18n.statsMarketTitle),
+                  ul([
+                    li(`${i18n.statsMarketTotal}: ${stats.marketKPIs?.total || 0}`),
+                    li(`${i18n.statsMarketForSale}: ${stats.marketKPIs?.forSale || 0}`),
+                    li(`${i18n.statsMarketReserved}: ${stats.marketKPIs?.reserved || 0}`),
+                    li(`${i18n.statsMarketClosed}: ${stats.marketKPIs?.closed || 0}`),
+                    li(`${i18n.statsMarketSold}: ${stats.marketKPIs?.sold || 0}`),
+                    li(`${i18n.statsMarketRevenue}: ${((stats.marketKPIs?.revenueECO || 0)).toFixed(6)} ECO`),
+                    li(`${i18n.statsMarketAvgSoldPrice}: ${((stats.marketKPIs?.avgSoldPrice || 0)).toFixed(6)} ECO`)
+                  ])
+                ),
+                div({ style: blockStyle },
+                  h2(i18n.statsProjectsTitle),
+                  ul([
+                    li(`${i18n.statsProjectsTotal}: ${stats.projectsKPIs?.total || 0}`),
+                    li(`${i18n.statsProjectsActive}: ${stats.projectsKPIs?.active || 0}`),
+                    li(`${i18n.statsProjectsCompleted}: ${stats.projectsKPIs?.completed || 0}`),
+                    li(`${i18n.statsProjectsPaused}: ${stats.projectsKPIs?.paused || 0}`),
+                    li(`${i18n.statsProjectsCancelled}: ${stats.projectsKPIs?.cancelled || 0}`),
+                    li(`${i18n.statsProjectsGoalTotal}: ${(stats.projectsKPIs?.ecoGoalTotal || 0)} ECO`),
+                    li(`${i18n.statsProjectsPledgedTotal}: ${(stats.projectsKPIs?.ecoPledgedTotal || 0)} ECO`),
+                    li(`${i18n.statsProjectsSuccessRate}: ${((stats.projectsKPIs?.successRate || 0)).toFixed(1)}%`),
+                    li(`${i18n.statsProjectsAvgProgress}: ${((stats.projectsKPIs?.avgProgress || 0)).toFixed(1)}%`),
+                    li(`${i18n.statsProjectsMedianProgress}: ${((stats.projectsKPIs?.medianProgress || 0)).toFixed(1)}%`),
+                    li(`${i18n.statsProjectsActiveFundingAvg}: ${((stats.projectsKPIs?.activeFundingAvg || 0)).toFixed(1)}%`)
+                  ])
+                ),
+                div({ style: blockStyle },
+                  h2(`${i18n.statsYourOpinions}: ${totalOpinions}`),
+                  ul(types.map(t => O(stats, t) > 0 ? li(`${i18n[`stats${t.charAt(0).toUpperCase() + t.slice(1)}`]}: ${O(stats, t)}`) : null).filter(Boolean))
+                ),
+                div({ style: blockStyle },
+                  h2(`${i18n.statsYourContent}: ${totalContent}`),
+                  ul(types.map(t => C(stats, t) > 0 ? li(`${i18n[`stats${t.charAt(0).toUpperCase() + t.slice(1)}`]}: ${C(stats, t)}`) : null).filter(Boolean))
+                )
+              ])
             : div({ class: 'stats-container' }, [
-              div({ style: blockStyle },
-                h2(`${i18n.TOMBSTONEButton}: ${stats.userTombstoneCount}`)
-              )
-            ])
+                div({ style: blockStyle },
+                  h2(`${i18n.TOMBSTONEButton}: ${stats.userTombstoneCount}`),
+                  h2(`${i18n.statsTombstoneRatio.toUpperCase()}: ${((stats.tombstoneKPIs?.ratio || 0)).toFixed(2)}%`)
+                )
+              ])
       )
     )
   );
 };
+

+ 138 - 161
src/views/wallet_view.js

@@ -1,191 +1,168 @@
-const { form, button, div, h2, p, section, input, span, table, thead, tr, td, th, ul, li, a } = require("../server/node_modules/hyperaxe");
+const { form, button, div, h2, p, section, input, span, table, thead, tbody, tr, td, th, ul, li, a, br, label } = require("../server/node_modules/hyperaxe");
 const QRCode = require('../server/node_modules/qrcode');
 const { template, i18n } = require('./main_views');
 
 const walletViewRender = (balance, address, ...elements) => {
-  const header = div({ class: 'tags-header' },
-    h2(i18n.walletTitle),
-    p(i18n.walletDescription)
-  );
-
-  return template(
-    i18n.walletTitle,
-    section(
-      header,
-      div({ class: "wallet-section" },
-        h2(i18n.walletAddress),
-        div({ class: "wallet-address" },
-          h2({ class: "element" }, address)
-        ),
-        h2(i18n.walletBalanceTitle),
-        div({ class: "div-center" },
-          h2(i18n.walletBalanceLine({ balance }))
-        ),
-        div({ class: "div-center" },
-          span({ class: "wallet-form-button-group-center" },
-            form({ action: "/wallet/send", method: "get" },
-              button({ type: 'submit' }, i18n.walletSend)
-            ),
-            form({ action: "/wallet/receive", method: "get" },
-              button({ type: 'submit' }, i18n.walletReceive)
-            ),
-            form({ action: "/wallet/history", method: "get" },
-              button({ type: 'submit' }, i18n.walletHistory)
+    const header = div({ class: 'tags-header' }, h2(i18n.walletTitle), p(i18n.walletDescription));
+    return template(
+        i18n.walletTitle,
+        section(
+            header,
+            div({ class: "wallet-section" },
+                h2(i18n.walletAddress),
+                div({ class: "wallet-address" }, h2({ class: "element" }, address || "-")),
+                h2(i18n.walletBalanceTitle),
+                div({ class: "div-center" }, h2(i18n.walletBalanceLine({ balance: Number(balance || 0).toFixed(6) }))),
+                div({ class: "div-center" },
+                    span({ class: "wallet-form-button-group-center" },
+                        form({ action: "/wallet/send", method: "get" }, button({ type: 'submit' }, i18n.walletSend)),
+                        form({ action: "/wallet/receive", method: "get" }, button({ type: 'submit' }, i18n.walletReceive)),
+                        form({ action: "/wallet/history", method: "get" }, button({ type: 'submit' }, i18n.walletHistory))
+                    )
+                )
             )
-          )
-        )
-      )
-    ),
-    elements.length > 0 ? section(...elements) : null
-  );
+        ),
+        elements.length > 0 ? section(...elements) : null
+    );
 };
 
 exports.walletView = async (balance, address) => {
-  return walletViewRender(balance, address);
+    return walletViewRender(balance, address);
 };
 
 exports.walletHistoryView = async (balance, transactions, address) => {
-  return walletViewRender(
-    balance,
-    address,
-    h2(i18n.walletHistoryTitle),
-    table(
-      { class: "wallet-history" },
-      thead(
-        tr(
-          { class: "full-center" },
-          th({ class: "col-10" }, i18n.walletCnfrs),
-          th(i18n.walletDate),
-          th(i18n.walletType),
-          th(i18n.walletAmount),
-          th({ class: "col-30" }, i18n.walletTxId)
-        )
-      ),
-      tbody(
-        ...transactions.map((tx) => {
-          const date = new Date(tx.time * 1000);
-          const amount = Number(tx.amount);
-          const fee = Number(tx.fee) || 0;
-          const totalAmount = amount + fee;
-
-          return tr(
-            td({ class: "full-center" }, tx.confirmations),
-            td(date.toLocaleDateString(), br(), date.toLocaleTimeString()),
-            td(tx.category),
-            td(totalAmount.toFixed(2)),
-            td({ width: "30%", class: "tcell-ellipsis" },
-              a({
-                href: `https://ecoin.03c8.net/blockexplorer/search?q=${tx.txid}`,
-                target: "_blank",
-              }, tx.txid)
+    const rows = Array.isArray(transactions) ? transactions : [];
+    return walletViewRender(
+        balance,
+        address,
+        h2(i18n.walletHistoryTitle),
+        table(
+            { class: "wallet-history" },
+            thead(
+                tr(
+                    { class: "full-center" },
+                    th({ class: "col-10" }, i18n.walletCnfrs),
+                    th(i18n.walletDate),
+                    th(i18n.walletType),
+                    th(i18n.walletAmount),
+                    th({ class: "col-30" }, i18n.walletTxId)
+                )
+            ),
+            tbody(
+                ...rows.map(tx => {
+                    const date = new Date((tx.time || tx.timereceived || 0) * 1000);
+                    const amount = Number(tx.amount) || 0;
+                    const fee = Number(tx.fee) || 0;
+                    const totalAmount = amount + fee;
+                    return tr(
+                        td({ class: "full-center" }, String(tx.confirmations || 0)),
+                        td(date.toLocaleDateString(), br(), date.toLocaleTimeString()),
+                        td(tx.category || "-"),
+                        td(totalAmount.toFixed(6)),
+                        td({ width: "30%", class: "tcell-ellipsis" },
+                            a({ href: `https://ecoin.03c8.net/blockexplorer/search?q=${encodeURIComponent(tx.txid || '')}`, target: "_blank" }, tx.txid || "-")
+                        )
+                    );
+                })
             )
-          );
-        })
-      )
-    )
-  );
+        )
+    );
 };
 
 exports.walletReceiveView = async (balance, address) => {
-  const qrImage = await QRCode.toString(address, { type: 'svg' });
-
-  return walletViewRender(
-    balance,
-    address,
-    h2(i18n.walletReceiveTitle),
-    div({ class: 'div-center qr-code', innerHTML: qrImage })
-  );
+    const qrImage = await QRCode.toString(address || '', { type: 'svg' });
+    return walletViewRender(
+        balance,
+        address,
+        h2(i18n.walletReceiveTitle),
+        div({ class: 'div-center qr-code', innerHTML: qrImage })
+    );
 };
 
 exports.walletSendFormView = async (balance, destination, amount, fee, statusMessages, address) => {
-  const { type, title, messages } = statusMessages || {};
-  const statusBlock = div({ class: `wallet-status-${type}` });
-
-  if (messages?.length > 0) {
-    statusBlock.appendChild(span(i18n.walletStatusMessages[title]));
-    statusBlock.appendChild(
-      ul(...messages.map(error => li(i18n.walletStatusMessages[error])))
-    );
-  }
-
-  return walletViewRender(
-    balance,
-    address,
-    h2(i18n.walletWalletSendTitle),
-    div({ class: "div-center" },
-      messages?.length > 0 ? statusBlock : null,
-      form(
-        { action: '/wallet/send', method: 'POST' },
-        label({ for: 'destination' }, i18n.walletAddress), br(),
-        input({ type: 'text', id: 'destination', name: 'destination', placeholder: 'ETQ17sBv8QFoiCPGKDQzNcDJeXmB2317HX', value: destination }), br(),
-        label({ for: 'amount' }, i18n.walletAmount), br(),
-        input({ type: 'text', id: 'amount', name: 'amount', placeholder: '0.25', value: amount }), br(),
-        label({ for: 'fee' }, i18n.walletFee), br(),
-        input({ type: 'text', id: 'fee', name: 'fee', placeholder: '0.01', value: fee }), br(),
-        input({ type: 'hidden', name: 'action', value: 'confirm' }),
-        div({ class: 'wallet-form-button-group-center' },
-          button({ type: 'submit' }, i18n.walletSend),
-          button({ type: 'reset' }, i18n.walletReset)
+    const type = statusMessages?.type || 'info';
+    const titleKey = statusMessages?.title || '';
+    const messages = statusMessages?.messages || [];
+    const statusBlock = messages.length > 0
+        ? div(
+            { class: `wallet-status-${type}` },
+            span(i18n.walletStatusMessages[titleKey] || titleKey || ''),
+            ul(...messages.map(error => li(i18n.walletStatusMessages[error] || error)))
+        )
+        : null;
+
+    return walletViewRender(
+        balance,
+        address,
+        h2(i18n.walletWalletSendTitle),
+        div(
+            { class: "div-center" },
+            statusBlock,
+            form(
+                { action: '/wallet/send', method: 'POST' },
+                label({ for: 'destination' }, i18n.walletAddress), br(),
+                input({ type: 'text', id: 'destination', name: 'destination', placeholder: 'ETQ17sBv8QFoiCPGKDQzNcDJeXmB2317HX', value: destination || '' }), br(),
+                label({ for: 'amount' }, i18n.walletAmount), br(),
+                input({ type: 'text', id: 'amount', name: 'amount', placeholder: '0.25', value: amount || '' }), br(),
+                label({ for: 'fee' }, i18n.walletFee), br(),
+                input({ type: 'text', id: 'fee', name: 'fee', placeholder: '0.01', value: fee || '' }), br(),
+                input({ type: 'hidden', name: 'action', value: 'confirm' }),
+                div({ class: 'wallet-form-button-group-center' },
+                    button({ type: 'submit' }, i18n.walletSend),
+                    button({ type: 'reset' }, i18n.walletReset)
+                )
+            )
         )
-      )
-    )
-  );
+    );
 };
 
 exports.walletSendConfirmView = async (balance, destination, amount, fee) => {
-  const totalCost = amount + fee;
-
-  return walletViewRender(
-    balance,
-    p(
-      i18n.walletAddressLine({ address: destination }), br(),
-      i18n.walletAmountLine({ amount }), br(),
-      i18n.walletFeeLine({ fee }), br(),
-      i18n.walletTotalCostLine({ totalCost })
-    ),
-    form(
-      { action: '/wallet/send', method: 'POST' },
-      input({ type: 'hidden', name: 'action', value: 'send' }),
-      input({ type: 'hidden', name: 'destination', value: destination }),
-      input({ type: 'hidden', name: 'amount', value: amount }),
-      input({ type: 'hidden', name: 'fee', value: fee }),
-      div({ class: 'form-button-group-center' },
-        button({ type: 'submit' }, i18n.walletConfirm),
-        a({ href: `/wallet/send`, class: "button-like-link" }, i18n.walletBack)
-      )
-    )
-  );
+    const a = Number(amount || 0);
+    const f = Number(fee || 0);
+    const totalCost = a + f;
+    return walletViewRender(
+        balance,
+        destination,
+        p(
+            i18n.walletAddressLine({ address: destination || '-' }), br(),
+            i18n.walletAmountLine({ amount: a.toFixed(6) }), br(),
+            i18n.walletFeeLine({ fee: f.toFixed(6) }), br(),
+            i18n.walletTotalCostLine({ totalCost: totalCost.toFixed(6) })
+        ),
+        form(
+            { action: '/wallet/send', method: 'POST' },
+            input({ type: 'hidden', name: 'action', value: 'send' }),
+            input({ type: 'hidden', name: 'destination', value: destination || '' }),
+            input({ type: 'hidden', name: 'amount', value: String(a) }),
+            input({ type: 'hidden', name: 'fee', value: String(f) }),
+            div({ class: 'form-button-group-center' },
+                button({ type: 'submit' }, i18n.walletConfirm),
+                a({ href: `/wallet/send`, class: "button-like-link" }, i18n.walletBack)
+            )
+        )
+    );
 };
 
 exports.walletSendResultView = async (balance, destination, amount, txId) => {
-  return walletViewRender(
-    balance,
-    p(
-      i18n.walletSentToLine({ destination, amount }), br(),
-      `${i18n.walletTransactionId}: `,
-      a({
-        href: `https://ecoin.03c8.net/blockexplorer/search?q=${txId}`,
-        target: "_blank"
-      }, txId)
-    )
-  );
+    return walletViewRender(
+        balance,
+        destination,
+        p(
+            i18n.walletSentToLine({ destination: destination || '-', amount: Number(amount || 0).toFixed(6) }), br(),
+            `${i18n.walletTransactionId}: `,
+            a({ href: `https://ecoin.03c8.net/blockexplorer/search?q=${encodeURIComponent(txId || '')}`, target: "_blank" }, txId || '-')
+        )
+    );
 };
 
 exports.walletErrorView = async (error) => {
-  const header = div({ class: 'tags-header' },
-    h2(i18n.walletTitle),
-    p(i18n.walletDescription)
-  );
-
-  return template(
-    i18n.walletTitle,
-    section(
-      header,
-      div({ class: "wallet-error" },
-        h2(i18n.walletStatus),
-        p(i18n.walletDisconnected)
-      )
-    )
-  );
+    const header = div({ class: 'tags-header' }, h2(i18n.walletTitle), p(i18n.walletDescription));
+    return template(
+        i18n.walletTitle,
+        section(
+            header,
+            div({ class: "wallet-error" }, h2(i18n.walletStatus), p(i18n.walletDisconnected))
+        )
+    );
 };