diff --git a/harvest-finance/frontend/package.json b/harvest-finance/frontend/package.json index 7f783569..b0dad101 100644 --- a/harvest-finance/frontend/package.json +++ b/harvest-finance/frontend/package.json @@ -52,6 +52,7 @@ "zustand": "^5.0.11", "react-toastify": "^9.1.3", "next-intl": "^3.25.1" + ,"fuse.js": "^6.6.2" }, "devDependencies": { "@jest/types": "^30.3.0", diff --git a/harvest-finance/frontend/scripts/validate-help.js b/harvest-finance/frontend/scripts/validate-help.js new file mode 100644 index 00000000..b9be34de --- /dev/null +++ b/harvest-finance/frontend/scripts/validate-help.js @@ -0,0 +1,37 @@ +const fs = require('fs'); +const path = require('path'); + +const articles = JSON.parse(fs.readFileSync(path.join(__dirname, '../src/content/help/articles.json'), 'utf8')); + +function simpleSearch(query, limit = 10) { + if (!query || query.trim() === '') return []; + const q = query.toLowerCase(); + const out = articles.filter(a => { + return ( + a.title.toLowerCase().includes(q) || + a.body.toLowerCase().includes(q) || + (a.keywords || []).some(k => k.toLowerCase().includes(q)) || + a.category.toLowerCase().includes(q) + ); + }); + return out.slice(0, limit); +} + +function assert(condition, msg) { + if (!condition) { + console.error('FAIL:', msg); + process.exitCode = 2; + return; + } + console.log('OK:', msg); +} + +console.log('Validating help search...'); +assert(articles.length > 0, 'articles loaded'); +const r1 = simpleSearch('deposit'); +assert(r1.length > 0, 'deposit query returns results'); +assert(r1[0].title.toLowerCase().includes('deposit'), 'first result likely deposit article'); +const r2 = simpleSearch('qwertyuiopzz'); +assert(r2.length === 0, 'nonsense query returns no results'); + +console.log('All validations passed.'); diff --git a/harvest-finance/frontend/src/__tests__/help-search.test.ts b/harvest-finance/frontend/src/__tests__/help-search.test.ts new file mode 100644 index 00000000..d345e702 --- /dev/null +++ b/harvest-finance/frontend/src/__tests__/help-search.test.ts @@ -0,0 +1,17 @@ +import { searchHelp, getAllArticles } from '../lib/help-search'; + +describe('help search', () => { + it('returns articles for common queries', () => { + const all = getAllArticles(); + expect(all.length).toBeGreaterThan(0); + + const res = searchHelp('deposit'); + expect(res.length).toBeGreaterThan(0); + expect(res[0].title.toLowerCase()).toContain('deposit'); + }); + + it('returns empty for unknown query', () => { + const res = searchHelp('qwertyuiopzz'); + expect(res.length).toBe(0); + }); +}); diff --git a/harvest-finance/frontend/src/app/help/page.tsx b/harvest-finance/frontend/src/app/help/page.tsx new file mode 100644 index 00000000..cd46f0c9 --- /dev/null +++ b/harvest-finance/frontend/src/app/help/page.tsx @@ -0,0 +1,131 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from 'react'; +import { searchHelp, getAllArticles, Article } from '../../../lib/help-search'; + +const NO_RESULTS_KEY = 'help_no_results'; + +function saveHelpful(articleId: string, helpful: boolean) { + const key = `helpful_${articleId}`; + try { + localStorage.setItem(key, helpful ? '1' : '0'); + } catch (e) {} +} + +function getHelpful(articleId: string) { + try { + const v = localStorage.getItem(`helpful_${articleId}`); + if (v === '1') return true; + if (v === '0') return false; + } catch (e) {} + return undefined; +} + +function recordNoResults(query: string) { + try { + const raw = localStorage.getItem(NO_RESULTS_KEY); + const obj = raw ? JSON.parse(raw) : {}; + obj[query] = (obj[query] || 0) + 1; + localStorage.setItem(NO_RESULTS_KEY, JSON.stringify(obj)); + } catch (e) {} +} + +export default function HelpPage() { + const all = useMemo(() => getAllArticles(), []); + const [query, setQuery] = useState(''); + const [results, setResults] = useState(null); + + useEffect(() => { + if (!query) { + setResults(null); + return; + } + const r = searchHelp(query, 20); + setResults(r); + if (r.length === 0) recordNoResults(query); + }, [query]); + + const categories = useMemo(() => { + const map = new Map(); + all.forEach(a => { + if (!map.has(a.category)) map.set(a.category, []); + map.get(a.category)!.push(a); + }); + return Array.from(map.entries()); + }, [all]); + + return ( +
+

Help Centre

+

Search our FAQs or browse by category.

+ setQuery(e.target.value)} + style={{ width: '100%', padding: 8, marginBottom: 12 }} + /> + + {results !== null ? ( +
+

Search results

+ {results.length === 0 ? ( +

No results found for "{query}"

+ ) : ( + results.map(a => ) + )} +
+ ) : ( +
+ {categories.map(([cat, arts]) => ( +
+

{cat}

+ {arts.map(a => ( + + ))} +
+ ))} +
+ )} +
+ ); +} + +function ArticleCard({ article }: { article: Article }) { + const [helpful, setHelpful] = useState(undefined); + + useEffect(() => { + setHelpful(getHelpful(article.id)); + }, [article.id]); + + return ( +
+

{article.title}

+

{article.body}

+
+ Was this helpful? + + + {helpful === true && Thanks — helpful} + {helpful === false && Thanks — we'll improve} +
+
+ ); +} diff --git a/harvest-finance/frontend/src/content/help/articles.json b/harvest-finance/frontend/src/content/help/articles.json new file mode 100644 index 00000000..9c19ad76 --- /dev/null +++ b/harvest-finance/frontend/src/content/help/articles.json @@ -0,0 +1,37 @@ +[ + { + "id": "deposits-1", + "title": "How do I deposit funds into a vault?", + "category": "Deposits", + "body": "To deposit, navigate to the vault page, click Deposit, enter the amount and confirm the transaction with your wallet.", + "keywords": ["deposit","funds","vault","add funds"] + }, + { + "id": "withdrawals-1", + "title": "How do withdrawals work?", + "category": "Withdrawals", + "body": "Withdrawals remove your funds from a vault. Depending on the strategy there may be a delay or on-chain fees.", + "keywords": ["withdraw","remove","vault","redeem"] + }, + { + "id": "strategies-1", + "title": "What are vault strategies?", + "category": "Vault Strategies", + "body": "Vault strategies determine how deposited assets are allocated to earn yield. Strategies are managed by the protocol and can change over time.", + "keywords": ["strategy","yield","allocation","strategy change"] + }, + { + "id": "wallets-1", + "title": "How do I connect my wallet?", + "category": "Wallet Connections", + "body": "Click the Connect button and choose your wallet provider. Approve the connection in the wallet popup.", + "keywords": ["wallet","connect","freighter","metamask"] + }, + { + "id": "fees-1", + "title": "What fees does the protocol charge?", + "category": "Fees", + "body": "Fees may include withdrawal fees, performance fees, and protocol fees. Check the vault page for exact fee details.", + "keywords": ["fees","withdrawal fee","performance fee","protocol fee"] + } +] diff --git a/harvest-finance/frontend/src/lib/help-search.ts b/harvest-finance/frontend/src/lib/help-search.ts new file mode 100644 index 00000000..abef8a7d --- /dev/null +++ b/harvest-finance/frontend/src/lib/help-search.ts @@ -0,0 +1,50 @@ +let Fuse: any = null; +try { + // try dynamic require so tests can run without installing fuse.js + // eslint-disable-next-line @typescript-eslint/no-var-requires + // @ts-ignore + Fuse = require('fuse.js'); +} catch (e) { + Fuse = null; +} +import articles from '../content/help/articles.json'; + +export type Article = { + id: string; + title: string; + category: string; + body: string; + keywords?: string[]; +}; + +const options = { + keys: ['title', 'body', 'keywords', 'category'], + includeScore: true, + threshold: 0.4, +}; + +export function searchHelp(query: string, limit = 10) { + if (!query || query.trim() === '') return [] as Article[]; + if (Fuse) { + const fuse = new Fuse(articles as Article[], options); + const results = fuse.search(query, { limit }); + return results.map((r: any) => r.item as Article); + } + // fallback simple substring search + const q = query.toLowerCase(); + const out = (articles as Article[]).filter(a => { + return ( + a.title.toLowerCase().includes(q) || + a.body.toLowerCase().includes(q) || + (a.keywords || []).some(k => k.toLowerCase().includes(q)) || + a.category.toLowerCase().includes(q) + ); + }); + return out.slice(0, limit); +} + +export function getAllArticles(): Article[] { + return articles as Article[]; +} + +export default { searchHelp, getAllArticles };