import React, { useEffect, useRef, useState } from 'react'; import { FaUpload } from 'react-icons/fa'; import type { BrandResponse, VisibilitySettingsResponse, } from '../../service/visibility/visibility.interface'; import { DOCUMENT_ACCEPT, extractDocumentText } from '../../utils/documentText'; import { createBrand, patchBrand, updateSettings, } from '../../service/visibility/visibility.service'; import { setSetupProgressStep } from '../../service/setup-progress/setup-progress.service'; import { WORDPRESS_STEP_KEYS } from '../../service/setup-progress/setup-progress.constants'; import { addBulkTags, containsBulkSeparator } from '../../utils/bulkTags'; import { COUNTRY_OPTIONS, LANGUAGE_OPTIONS } from '../../data/locales'; import SearchableSelect from '../widgets/SearchableSelect'; import Modal from './Modal'; const MAX_TAGS = 12; /** Hard cap on the knowledge base text - matches the brand API schema. */ const MAX_KNOWLEDGE_BASE_CHARS = 10_000; interface SettingsDrawerProps { open: boolean; onClose: () => void; brand: BrandResponse | null; settings: VisibilitySettingsResponse | null; clientId: string; token: string; fallbackDomain?: string; /** * Storefront language detected from the host platform (the WordPress * site locale). Preferred over the merchant-wide * ``preferred_language`` because that one tracks the Recomaze admin * UI, not the storefront the shoppers and AI assistants actually see. */ fallbackLanguage?: string; onBrandSaved: (brand: BrandResponse) => void; onSettingsSaved: (settings: VisibilitySettingsResponse) => void; } const TagChip = ({ value, onRemove, }: { value: string; onRemove: () => void; }): JSX.Element => ( {value} ); /** * Side panel for brand + merchant-wide visibility settings. Saves touch * two endpoints: * - Brand-scope → ``PATCH /brands/{id}`` (or ``POST /brands`` when missing) * - Merchant-scope → ``PUT /settings`` */ const SettingsDrawer = ({ open, onClose, brand, settings, clientId, token, fallbackDomain, fallbackLanguage, onBrandSaved, onSettingsSaved, }: SettingsDrawerProps): JSX.Element => { const [language, setLanguage] = useState('en'); const [country, setCountry] = useState(''); const [domain, setDomain] = useState(''); const [brandName, setBrandName] = useState(''); const [niche, setNiche] = useState(''); const [businessType, setBusinessType] = useState(''); const [keywords, setKeywords] = useState([]); const [keywordDraft, setKeywordDraft] = useState(''); const [keywordStatus, setKeywordStatus] = useState(null); const [differentiators, setDifferentiators] = useState([]); const [differentiatorDraft, setDifferentiatorDraft] = useState(''); const [differentiatorStatus, setDifferentiatorStatus] = useState< string | null >(null); const [knowledgeBase, setKnowledgeBase] = useState(''); const [knowledgeBaseFileError, setKnowledgeBaseFileError] = useState< string | null >(null); const knowledgeFileInputRef = useRef(null); const [weeklyEmail, setWeeklyEmail] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (open) { // Order: explicit brand language → detected storefront (WP) locale // → merchant-wide preferred_language → English. Storefront wins // over preferred_language so a Romanian WordPress store doesn't // get scanned in English just because the admin UI is in English. setLanguage( brand?.language || fallbackLanguage || settings?.preferred_language || 'en' ); setCountry(brand?.country || settings?.default_country || ''); setDomain(brand?.domain || fallbackDomain || ''); setBrandName(brand?.brand_name || ''); setNiche(brand?.niche || ''); setBusinessType(brand?.business_type || ''); setKeywords( Array.isArray(brand?.keywords) ? [...(brand?.keywords ?? [])] : [] ); setKeywordDraft(''); setKeywordStatus(null); setDifferentiators( Array.isArray(brand?.differentiators) ? [...(brand?.differentiators ?? [])] : [] ); setDifferentiatorDraft(''); setDifferentiatorStatus(null); setKnowledgeBase(brand?.knowledge_base || ''); setKnowledgeBaseFileError(null); setWeeklyEmail(true); setError(null); } }, [open, brand, settings, fallbackDomain, fallbackLanguage]); /** * Read an attached .txt/.csv/.docx into the knowledge base textarea, * truncated to the field's character budget so the merchant can review and * edit it before saving. * * @param file {File | null} The selected file, or null when cleared. * @returns {Promise} Resolves once the file text has been loaded. */ const handleKnowledgeFile = async (file: File | null): Promise => { setKnowledgeBaseFileError(null); if (!file) return; const result = await extractDocumentText(file, MAX_KNOWLEDGE_BASE_CHARS); if (result.error) { setKnowledgeBaseFileError(result.error); return; } setKnowledgeBase(result.text ?? ''); }; /** * Strip protocol, ``www.``, paths and query strings, and validate the * result looks like a real hostname (at least one dot, no whitespace, * all-ASCII). Backend also runs a reachability probe on its end, but * we catch the obvious mistakes (``t``, ``localhost``, spaces) client * side so the merchant doesn't hit a round-trip for a typo. */ const normaliseDomain = (raw: string): string => { let d = raw.trim().toLowerCase(); d = d.replace(/^https?:\/\//, ''); d = d.replace(/^www\./, ''); d = d.split('/')[0].split('?')[0].split('#')[0]; return d; }; const isValidDomain = (d: string): boolean => { if (!d || d.length < 4) return false; if (/\s/.test(d)) return false; if (!d.includes('.')) return false; return /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(d); }; /** * Adds one or many tags from ``value`` (typed draft, Enter, Add click, * or pasted blob). Comma / newline / tab separators in ``value`` are * treated as a bulk paste and split into individual tags. Trimming, * empty-drop, case-insensitive dedupe, and the {@link MAX_TAGS} cap * are all enforced by {@link addBulkTags}. */ const commitTags = ( value: string, current: string[], setCurrent: (next: string[]) => void, setDraft: (next: string) => void, setStatus: (next: string | null) => void ) => { const result = addBulkTags(value, current, MAX_TAGS); if (result.added.length > 0) { setCurrent(result.next); } setDraft(''); if (result.total > 0 && result.added.length < result.total) { setStatus(`${result.added.length} of ${result.total} added`); } else { setStatus(null); } }; /** * Paste handler shared by both pill inputs. Single-value pastes (no * separators) fall through to the default browser behavior; multi-value * pastes are intercepted and routed through {@link commitTags}. */ const handleTagPaste = ( event: React.ClipboardEvent, current: string[], setCurrent: (next: string[]) => void, setDraft: (next: string) => void, setStatus: (next: string | null) => void ) => { const text = event.clipboardData.getData('text'); if (!containsBulkSeparator(text)) return; event.preventDefault(); commitTags(text, current, setCurrent, setDraft, setStatus); }; const handleSave = async () => { if (!settings) return; setSaving(true); setError(null); // When creating a brand, validate the domain client-side so we don't // hit the backend just to learn the domain is malformed (the backend // also runs a reachability probe; that's a separate check). let normalisedDomain = ''; if (!brand) { normalisedDomain = normaliseDomain(domain); if (!isValidDomain(normalisedDomain)) { setError( 'Enter a valid domain (e.g. mystore.com). Protocols and paths are stripped automatically.' ); setSaving(false); return; } } try { const brandPromise = brand ? patchBrand(clientId, token, brand.brand_id, { brand_name: brandName.trim() || undefined, language, country: country || undefined, niche: niche.trim() || undefined, business_type: businessType.trim() || undefined, keywords, differentiators, knowledge_base: knowledgeBase.trim(), }) : createBrand(clientId, token, { domain: normalisedDomain, brand_name: brandName.trim() || undefined, language, country: country || undefined, niche: niche.trim() || undefined, business_type: businessType.trim() || undefined, keywords, differentiators, knowledge_base: knowledgeBase.trim() || undefined, }); const [savedBrand, savedSettings] = await Promise.all([ brandPromise, updateSettings(clientId, token, { preferred_language: language, default_country: country || undefined, }), ]); onBrandSaved(savedBrand); onSettingsSaved(savedSettings); setSetupProgressStep(WORDPRESS_STEP_KEYS.RUN_AI_VISIBILITY).catch( () => {} ); onClose(); } catch (err: any) { // Surface the backend's ``detail`` field when available (e.g. // "Domain is invalid or not reachable") - it's far more actionable // than a generic "Failed to save settings". const backendDetail = err?.response?.data?.detail || err?.response?.data?.message || (typeof err?.response?.data === 'string' ? err.response.data : null); setError( backendDetail || (err instanceof Error ? err.message : 'Failed to save settings.') ); } finally { setSaving(false); } }; const addButtonClass = (disabled: boolean): string => `rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium ${ disabled ? 'bg-gray-50 text-gray-400 cursor-not-allowed' : 'bg-black text-white hover:bg-gray-800' }`; return ( } >
{error && (
{error}
)}
setDomain(e.target.value)} disabled={!!brand} placeholder="mystore.com" autoComplete="off" className="rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:bg-gray-50 disabled:text-gray-500" />

The canonical domain of your storefront (e.g. mystore.com). Must be a real, reachable domain - the scanner uses it to ground AI search queries. Cannot be changed after the brand is created.

setBrandName(e.target.value)} placeholder="Optional - defaults to the domain" autoComplete="off" className="rounded-md border border-gray-300 px-3 py-1.5 text-sm" />

Drives the language of problem reasons, article content, and suggested topics.

Optional geo hint the scanner uses when grounding searches.

{/* How AI sees your brand */}

How AI sees your brand

These were detected from your domain. Override only if Gemini got something wrong - bad inputs here will push the scanner toward the wrong prompts.

setNiche(e.target.value)} placeholder="e.g. premium skincare, B2B SaaS analytics, eco-friendly home goods" autoComplete="off" className="rounded-md border border-gray-300 px-3 py-1.5 text-sm" />

One sentence fragment is enough. Leave empty to let Gemini decide.

setBusinessType(e.target.value)} placeholder="e.g. ecommerce, saas, agency, marketplace" autoComplete="off" className="rounded-md border border-gray-300 px-3 py-1.5 text-sm" />
{/* Knowledge base */}
New Optional
{ void handleKnowledgeFile(e.target.files?.[0] ?? null); e.target.value = ''; }} />

Tell us about your business in your own words - what you sell, who you sell to, your positioning and what makes you different. We feed it to the AI so the tracked prompts and the keywords we monitor reflect your real market instead of generic category guesses. Paste text or upload a .txt, .csv or .docx.

When filled, the knowledge base takes priority: your keywords and tracked prompts are rebuilt from it on the next Force scan. Your products are still used to write the articles, but the angles are framed around your knowledge base.