/** * GlossaryText — renders a text string with DIY/unique terms as tappable, * underlined chips that open a popup explaining the term (definition + how-to). * * Uses the AI-returned glossary (Option B) merged with a curated fallback * (Option A) for common terms. Shared by the cutting modal and the plant * detail propagation section so glossary terms are tappable everywhere. */ import React, { useMemo, useState } from 'react'; import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { FALLBACK_PROPAGATION_GLOSSARY, type PropagationGlossaryEntry, type PropagationInfo, } from '../types/propagation'; import { dark } from '../constants/theme'; interface GlossaryTextProps { text: string; style?: object; /** Propagation info carrying the AI-returned glossary (Option B). */ propagationInfo?: PropagationInfo | null; } export function GlossaryText({ text, style, propagationInfo }: GlossaryTextProps) { const [glossaryTerm, setGlossaryTerm] = useState(null); // Merge AI glossary + fallback, keyed by lowercase term. const glossaryMap = useMemo(() => { const map: Record = {}; for (const entry of propagationInfo?.glossary || []) { if (entry?.term) map[entry.term.toLowerCase()] = entry; } for (const [key, entry] of Object.entries(FALLBACK_PROPAGATION_GLOSSARY)) { if (!map[key]) map[key] = entry; } return map; }, [propagationInfo]); if (!text) return null; const lower = text.toLowerCase(); const terms = Object.keys(glossaryMap).filter(t => t.length >= 3 && lower.includes(t)); if (terms.length === 0) { return {text}; } // Sort by length desc so "willow water" matches before "willow". terms.sort((a, b) => b.length - a.length); const segments: React.ReactNode[] = []; let i = 0; while (i < text.length) { let matched: string | null = null; for (const term of terms) { if (text.toLowerCase().startsWith(term, i)) { matched = term; break; } } if (matched) { const entry = glossaryMap[matched]; segments.push( entry && setGlossaryTerm(entry)} > {text.slice(i, i + matched.length)} ); i += matched.length; } else { let j = i + 1; while (j <= text.length) { const hit = terms.some(t => text.toLowerCase().startsWith(t, j)); if (hit || j === text.length) break; j++; } segments.push({text.slice(i, j)}); i = j; } } return ( <> {segments} setGlossaryTerm(null)}> {glossaryTerm?.term} {glossaryTerm?.definition ? ( {glossaryTerm.definition} ) : null} {glossaryTerm?.howToMake ? ( <> How to make / get it {glossaryTerm.howToMake} ) : null} setGlossaryTerm(null)}> Got it ); } const s = StyleSheet.create({ glossaryTerm: { color: '#2E6B3F', fontWeight: '800', textDecorationLine: 'underline', }, overlay: { flex: 1, backgroundColor: 'rgba(16, 42, 16, 0.6)', justifyContent: 'center', alignItems: 'center', padding: 24, }, card: { width: '100%', maxWidth: 420, backgroundColor: '#FFFCF5', borderRadius: 20, padding: 22, }, title: { fontSize: 20, fontWeight: '900', color: dark, marginBottom: 8, }, definition: { fontSize: 15, color: '#3A3A33', lineHeight: 22, marginBottom: 12, }, howLabel: { fontSize: 13, fontWeight: '800', color: '#2E6B3F', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 4, }, howText: { fontSize: 15, color: '#3A3A33', lineHeight: 22, marginBottom: 16, }, close: { minHeight: 48, borderRadius: 24, backgroundColor: dark, alignItems: 'center', justifyContent: 'center', }, closeText: { color: '#fff', fontSize: 16, fontWeight: '900', }, });