Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@rainbow-me/rainbowkit": "^2.2.9",
"@tanstack/react-query": "^5.90.10",
"dompurify": "^3.3.0",
"fuse.js": "^7.1.0",
"graphql": "^16.8.0",
"graphql-ws": "^6.0.6",
"i18next": "^25.6.3",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/founder/FounderCenterPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ export function FounderCenterPanel({
<TotemCreationForm
onChange={(data) => onNewTotemChange?.(data)}
dynamicCategories={dynamicCategories}
existingTotems={ofcTotems}
onTotemCreated={onTotemCreated}
/>
)}
Expand Down
58 changes: 51 additions & 7 deletions apps/web/src/components/founder/TotemCreationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import categoriesData from '../../../../../packages/shared/src/data/categories.json';
import type { CategoryConfigType } from '../../types/category';
import type { DynamicCategory } from '../../hooks/data/useAllOFCTotems';
import type { DynamicCategory, OFCTotem } from '../../hooks/data/useAllOFCTotems';
import {
useCreateTotemWithTriples,
type TotemCreationResult,
} from '../../hooks/blockchain/claims/useCreateTotemWithTriples';
import { uploadImageToPinata } from '../../utils/pinataUpload';
import { useFuzzySearch } from '../../hooks/search/useFuzzySearch';

// Type the JSON imports
const typedCategoriesConfig = categoriesData as CategoryConfigType;
Expand All @@ -47,6 +48,8 @@ interface TotemCreationFormProps {
onChange: (data: NewTotemData | null) => void;
/** Dynamic categories from blockchain (user-created) */
dynamicCategories?: DynamicCategory[];
/** Existing totems for duplicate detection */
existingTotems?: OFCTotem[];
/** Called when a totem is successfully created on-chain */
onTotemCreated?: (result: TotemCreationResult) => void;
}
Expand All @@ -57,6 +60,7 @@ const STATIC_CATEGORIES = typedCategoriesConfig.categories;
export function TotemCreationForm({
onChange,
dynamicCategories = [],
existingTotems = [],
onTotemCreated,
}: TotemCreationFormProps) {
const { t } = useTranslation();
Expand Down Expand Up @@ -105,6 +109,15 @@ export function TotemCreationForm({
return merged.sort((a, b) => (a.label || '').localeCompare(b.label || ''));
}, [dynamicCategories]);

// Fuzzy search: filter categories as user types
const categoryMatches = useFuzzySearch(allCategories, ['label'], customCategoryInput);
const visibleCategories = customCategoryInput.trim().length < 2
? allCategories
: categoryMatches.map(r => r.item);

// Fuzzy search: detect similar existing totems
const totemMatches = useFuzzySearch(existingTotems, ['label'], totemName);

// Determine if using a custom category (input has text and no chip selected)
const isNewCategory = customCategoryInput.trim().length > 0 && selectedCategory === '';

Expand Down Expand Up @@ -361,6 +374,23 @@ export function TotemCreationForm({
placeholder={t('creation.totemNamePlaceholder')}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-sm text-white placeholder-white/30 focus:outline-none focus:border-slate-500/50 focus:ring-1 focus:ring-slate-500/30"
/>
{/* Similar totem suggestions */}
{totemMatches.length > 0 && (
<div className="mt-1.5 space-y-1">
{totemMatches.slice(0, 3).map((match) => (
<button
key={match.item.id}
onClick={() => {
setTotemName(match.item.label);
if (match.item.category) handleCategorySelect(match.item.category);
}}
className="w-full text-left text-[10px] text-amber-400/70 hover:text-amber-300 transition-colors px-2 py-1 bg-amber-500/5 border border-amber-500/10 rounded-md"
>
{t('creation.similarTotemFound', { name: match.item.label, category: match.item.category || '?' })}
</button>
))}
</div>
)}
</div>

{/* 2. Image (optional) — drop zone + URL input */}
Expand Down Expand Up @@ -434,9 +464,9 @@ export function TotemCreationForm({
{t('creation.category')}
</label>

{/* Category chips - styled like triple tags */}
<div className="flex flex-wrap gap-2 mb-2">
{allCategories.map((cat) => {
{/* Category chips - flex wrap limited to ~3 rows, scroll for overflow */}
<div className="flex flex-wrap gap-1.5 mb-2 max-h-[96px] overflow-y-auto pr-1 scrollbar-thin">
{visibleCategories.map((cat) => {
const isDynamic = !STATIC_CATEGORIES.some((s) => s.label === cat.label);
const isActive = selectedCategory === cat.label;
return (
Expand Down Expand Up @@ -484,9 +514,23 @@ export function TotemCreationForm({
}`}
/>
{isNewCategory && (
<p className="text-[10px] text-orange-400/70 mt-1">
{t('creation.newCategoryInfo')}
</p>
categoryMatches.length > 0 ? (
<div className="mt-1 space-y-1">
{categoryMatches.slice(0, 3).map((match) => (
<button
key={match.item.id}
onClick={() => handleCategorySelect(match.item.label)}
className="w-full text-left text-[10px] text-amber-400/70 hover:text-amber-300 transition-colors px-2 py-1 bg-amber-500/5 border border-amber-500/10 rounded-md"
>
{t('creation.similarCategoryFound', { name: match.item.label })}
</button>
))}
</div>
) : (
<p className="text-[10px] text-orange-400/70 mt-1">
{t('creation.newCategoryInfo')}
</p>
)
)}

{/* Category image — only when creating a new category */}
Expand Down
35 changes: 35 additions & 0 deletions apps/web/src/hooks/search/useFuzzySearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Fuse, { type IFuseOptions, type FuseResult } from 'fuse.js';
import { useMemo, useRef } from 'react';

const DEFAULT_OPTIONS: Partial<IFuseOptions<unknown>> = {
threshold: 0.3,
minMatchCharLength: 2,
includeScore: true,
};

/**
* Generic fuzzy search hook using fuse.js
*
* Returns matching results when query has >= 2 chars, empty array otherwise.
* Fuse instance is memoized and only recreated when items change.
*/
export function useFuzzySearch<T>(
items: T[],
keys: string[],
query: string,
options?: Partial<IFuseOptions<T>>
): FuseResult<T>[] {
// Stable keys reference to avoid recreating Fuse on every render
const keysRef = useRef(keys);
keysRef.current = keys;

const fuse = useMemo(
() => new Fuse(items, { ...DEFAULT_OPTIONS, keys: keysRef.current, ...options } as IFuseOptions<T>),
[items, options]
);

return useMemo(() => {
if (!query || query.trim().length < 2) return [];
return fuse.search(query.trim());
}, [fuse, query]);
}
4 changes: 3 additions & 1 deletion apps/web/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,9 @@
"removeImage": "Remove image",
"dropOrClick": "Drop an image here or click to browse",
"pinataNotConfigured": "File upload not available (Pinata not configured)",
"categoryImage": "Category image (optional)"
"categoryImage": "Category image (optional)",
"similarCategoryFound": "Similar category: {{name}}. Use it?",
"similarTotemFound": "Similar totem: {{name}} (in {{category}}). Use it?"
},
"homePage": {
"title": "INTUITION",
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,9 @@
"removeImage": "Supprimer l'image",
"dropOrClick": "Glissez une image ici ou cliquez pour parcourir",
"pinataNotConfigured": "Upload de fichier indisponible (Pinata non configure)",
"categoryImage": "Image de la catégorie (optionnel)"
"categoryImage": "Image de la catégorie (optionnel)",
"similarCategoryFound": "Catégorie similaire : {{name}}. Utiliser ?",
"similarTotemFound": "Totem similaire : {{name}} (dans {{category}}). Utiliser ?"
},
"homePage": {
"title": "INTUITION",
Expand Down
Loading