ChemPal Documentation - v1.7.0
    Preparing search index...
    • The full settings panel shown in the drawer's Settings tab. Renders all user preference controls (currencies, locations, toggles, numeric limits, excluded products, etc.) and persists changes to userSettings via app context.

      Returns Element

      The settings panel element.

      // Rendered inside the Settings drawer tab.
      <SettingsPanel />
      export default function SettingsPanel() {
      const appContext = useAppContext();
      const [expanded, setExpanded] = useState<string | false>('behavior');
      // Developer-only fuzz controls stay hidden until the Konami hotkey unlocks them.
      const advancedMode = appContext?.advancedMode ?? false;

      if (!appContext) {
      return <div>{i18n('settings_loading')}</div>;
      }

      const [formState, updateSetting, isPending] = useActionState(
      (currentSettings: UserSettings, action: SettingAction): UserSettings => {
      let newSettings: UserSettings;
      switch (action.type) {
      case ACTION_TYPE.SWITCH_CHANGE:
      newSettings = { ...currentSettings, [action.name]: action.checked };
      break;
      case ACTION_TYPE.INPUT_CHANGE:
      newSettings = { ...currentSettings, [action.name]: action.value };
      break;
      case ACTION_TYPE.BUTTON_CLICK:
      newSettings = { ...currentSettings, [action.name]: action.value };
      break;
      case ACTION_TYPE.SUPPLIER_TOGGLE:
      newSettings = {
      ...currentSettings,
      suppliers: { ...currentSettings.suppliers, disabled: action.value },
      };
      break;
      case ACTION_TYPE.PRICE_TRACKING_CHANGE:
      newSettings = { ...currentSettings, priceTracking: action.value };
      break;
      case ACTION_TYPE.CACHE_CHANGE:
      newSettings = { ...currentSettings, caching: action.value };
      break;
      case ACTION_TYPE.RESTORE_DEFAULTS:
      newSettings = {
      ...currentSettings,
      showHelp: false,
      caching: { enabled: true, doNotCacheEmptyResults: true, ttlMinutes: 7200 },
      priceTracking: { enabled: true, maxDataPoints: 5 },
      fontSize: 'medium',
      openInTab: false,
      autoHideEmptyColumns: true,
      groupProductVariants: true,
      suppliers: { ...currentSettings.suppliers, disabled: [] },
      hideColumns: [
      'description',
      'uom',
      'sds',
      'specs',
      'coa',
      'cas',
      'pubchem',
      'formula',
      'moleweight',
      'purity',
      'concentration',
      ],
      };
      break;
      default:
      return currentSettings;
      }
      startTransition(() => {
      try {
      appContext.setUserSettings(newSettings);
      } catch (error) {
      console.error('Failed to update settings:', error);
      }
      });
      return newSettings;
      },
      appContext.userSettings,
      );

      const handleSwitchChange = (event: ChangeEvent<HTMLInputElement>) => {
      updateSetting({
      type: ACTION_TYPE.SWITCH_CHANGE,
      name: event.target.name,
      checked: event.target.checked,
      });
      };

      const handleInputChange = (
      event: SelectChangeEvent | ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
      ) => {
      updateSetting({
      type: ACTION_TYPE.INPUT_CHANGE,
      name: event.target.name,
      value: event.target.value,
      });
      };

      const handleButtonClick = (event: MouseEvent<HTMLDivElement>) => {
      if (!isButtonElement(event.target)) return;
      const { name, value } = event.target;
      if (name && value) {
      updateSetting({ type: ACTION_TYPE.BUTTON_CLICK, name, value });
      }
      };

      const handleRestoreDefaults = () => {
      updateSetting({ type: ACTION_TYPE.RESTORE_DEFAULTS });
      };

      const [excludedProducts, setExcludedProducts] = useState<ExcludedProductsMap>({});
      const [priceHistoryCleared, setPriceHistoryCleared] = useState(false);
      const [cacheCleared, setCacheCleared] = useState(false);
      const [cacheStats, setCacheStats] = useState<{ records: number; bytes: number }>();
      const [priceStats, setPriceStats] = useState<{
      products: number;
      points: number;
      bytes: number;
      }>();

      useEffect(() => {
      const load = async () => {
      try {
      const map = await loadExcludedProducts();
      setExcludedProducts(map);
      } catch (error) {
      console.warn('Failed to load excluded products:', error);
      }
      };
      load();
      }, []);

      // Loads cache + price-history stats in one pass. Sizes are the stores' JSON
      // byte sizes scaled by the origin's real storage usage, so they reflect the
      // true on-disk footprint rather than the raw serialized size. Cache records
      // combine the query and product-detail caches (variants live inline in product
      // entries, so they're counted within the product cache, not a separate store).
      const loadStorageStats = async () => {
      try {
      const [breakdown, series] = await Promise.all([
      getIdbStorageBreakdown(),
      getAllPriceSeries(),
      ]);
      const scale = await getStorageUsageScale(breakdown.totalBytes);

      const queryStore = breakdown.byStore[IDB_STORE.SUPPLIER_QUERY_CACHE];
      const productStore = breakdown.byStore[IDB_STORE.SUPPLIER_PRODUCT_DATA_CACHE];
      setCacheStats({
      records: queryStore.count + productStore.count,
      bytes: Math.round((queryStore.bytes + productStore.bytes) * scale),
      });

      setPriceStats({
      products: new Set(series.map((entry) => entry.productKey)).size,
      points: series.reduce((sum, entry) => sum + entry.points.length, 0),
      bytes: Math.round(breakdown.byStore[IDB_STORE.PRICE_HISTORY].bytes * scale),
      });
      } catch (error) {
      console.warn('Failed to load storage stats:', error);
      }
      };

      useEffect(() => {
      void loadStorageStats();
      }, []);

      const handleRemoveExcluded = async (key: string) => {
      try {
      await removeExcludedProduct(key);
      setExcludedProducts((prev) => {
      const next = { ...prev };
      delete next[key];
      return next;
      });
      } catch (error) {
      console.warn('Failed to remove excluded product:', error);
      }
      };

      const handleClearAllExcluded = async () => {
      try {
      await clearExcludedProducts();
      setExcludedProducts({});
      } catch (error) {
      console.warn('Failed to clear excluded products:', error);
      }
      };

      const handleClearPriceHistory = async () => {
      try {
      await clearPriceHistory();
      setPriceHistoryCleared(true);
      await loadStorageStats();
      } catch (error) {
      console.warn('Failed to clear price history:', error);
      }
      };

      // Clears the supplier query cache and product-detail cache (leaving price
      // history and other stores untouched), then refreshes the displayed stats.
      const handleClearCache = async () => {
      try {
      await Promise.all([clearSupplierQueryCache(), clearSupplierProductDataCache()]);
      setCacheCleared(true);
      await loadStorageStats();
      } catch (error) {
      console.warn('Failed to clear cache:', error);
      }
      };

      const excludedEntries = Object.entries(excludedProducts).sort(
      ([, a], [, b]) => b.excludedAt - a.excludedAt,
      );
      const excludedCount = excludedEntries.length;

      const currentSettings = formState || appContext.userSettings;
      const disabledSupplierCount = (currentSettings.suppliers?.disabled ?? []).length;

      // Toggles a supplier's disabled state. Switch on = enabled, so toggling off adds the
      // supplier's class name to the disabled deny-list and toggling on removes it.
      const handleSupplierToggle = (supplierClassName: SupplierClassName) => () => {
      const disabled = currentSettings.suppliers?.disabled ?? [];
      const next = disabled.includes(supplierClassName)
      ? disabled.filter((name) => name !== supplierClassName)
      : [...disabled, supplierClassName];
      updateSetting({ type: ACTION_TYPE.SUPPLIER_TOGGLE, value: next });
      };

      // The language setting may be stored as a full locale (e.g. "en-US") while the
      // dropdown lists base locale codes ("en", "pl") that have a translation. Match
      // on the base code, and fall back to empty when the stored language has no
      // shipped translation so the Select doesn't render an out-of-range value.
      const currentLanguageBase = (currentSettings.language ?? '').split('-')[0];
      const selectedLanguage = AVAILABLE_LOCALES.includes(currentLanguageBase)
      ? currentLanguageBase
      : '';

      // Cache TTL is entered in minutes; the hint below the input shows its day
      // equivalent, recomputed 0.5s after the user stops typing so it doesn't churn on
      // every keystroke. The debounced value holds minutes; the label is formatted each
      // render so it also follows a locale switch.
      const cacheTtlMinutes = Number(currentSettings.caching?.ttlMinutes ?? 0);
      const [debouncedTtlMinutes, setDebouncedTtlMinutes] = useState(cacheTtlMinutes);
      useEffect(() => {
      const timer = setTimeout(() => setDebouncedTtlMinutes(cacheTtlMinutes), 500);
      return () => clearTimeout(timer);
      }, [cacheTtlMinutes]);
      const cacheTtlDays = Math.round((debouncedTtlMinutes / 1440) * 100) / 100;
      const cacheTtlDaysLabel =
      debouncedTtlMinutes > 0
      ? i18n(cacheTtlDays === 1 ? 'settings_cache_ttl_days_one' : 'settings_cache_ttl_days_other', [
      String(cacheTtlDays),
      ])
      : undefined;

      // Fetch the live USD→currency rate for the selected currency and show it under
      // the dropdown. Fetched directly (not read from stored settings) so the hint
      // always reflects the current selection; getCurrencyRate is LRU-cached, so this
      // returns the same value the price table converts with. Hidden for USD (rate 1).
      const selectedCurrency = currentSettings.currency;
      const [displayRate, setDisplayRate] = useState<number | undefined>(undefined);
      useEffect(() => {
      if (!selectedCurrency || selectedCurrency === 'USD') {
      setDisplayRate(undefined);
      return;
      }
      let cancelled = false;
      const loadRate = async () => {
      try {
      const rate = await getCurrencyRate('USD', selectedCurrency);
      if (!cancelled) setDisplayRate(rate);
      } catch (error) {
      console.error('Failed to fetch currency rate for display', { error });
      if (!cancelled) setDisplayRate(undefined);
      }
      };
      void loadRate();
      return () => {
      cancelled = true;
      };
      }, [selectedCurrency]);

      const currencyRateHint =
      selectedCurrency && selectedCurrency !== 'USD' && displayRate !== undefined
      ? i18n('settings_currency_rate_hint', [
      new Intl.NumberFormat(undefined, { maximumFractionDigits: 4 }).format(displayRate),
      selectedCurrency,
      ])
      : undefined;

      const handleAccordionChange =
      (panel: string) => (_event: SyntheticEvent, isExpanded: boolean) => {
      setExpanded(isExpanded ? panel : false);
      };

      return (
      <Box>
      <Accordion
      expanded={expanded === 'behavior'}
      onChange={handleAccordionChange('behavior')}
      disableGutters
      >
      <AccordionSummary
      expandIcon={<ExpandMoreIcon />}
      className={styles['settings-panel__accordion-summary']}
      >
      <Typography variant="body2" fontWeight={500}>
      {i18n('settings_section_behavior')}
      </Typography>
      </AccordionSummary>
      <AccordionDetails className={styles['settings-panel__accordion-details']}>
      <List dense component="nav" aria-labelledby="behavior-list-subheader">
      {/* Currency — laid out as a column so the rate hint sits on its own
      right-aligned row below the dropdown (no wrap, and the label/dropdown
      row above it doesn't shift when the hint appears). */}
      <ListItem
      className={styles['settings-panel__helper-on-hover']}
      sx={{ flexDirection: 'column', alignItems: 'stretch', rowGap: 0.5 }}
      >
      <Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
      <ListItemText primary={i18n('settings_currency')} />
      <FormControl>
      <Autocomplete
      options={CURRENCIES}
      getOptionLabel={(option) => `${option.code} (${option.symbol})`}
      isOptionEqualToValue={(option, value) => option.code === value.code}
      filterOptions={currencyFilter}
      value={CURRENCIES.find((c) => c.code === currentSettings.currency) ?? undefined}
      onChange={(_event, option) =>
      updateSetting({
      type: ACTION_TYPE.INPUT_CHANGE,
      name: 'currency',
      value: option?.code ?? '',
      })
      }
      size="small"
      className={styles['settings-panel__input']}
      disabled={isPending}
      disableClearable
      renderInput={(params) => (
      <TextField {...params} placeholder={i18n('settings_currency')} />
      )}
      />
      </FormControl>
      </Box>
      {currencyRateHint && (
      <Typography
      variant="caption"
      color="text.secondary"
      sx={{ alignSelf: 'flex-end', whiteSpace: 'nowrap' }}
      >
      {currencyRateHint}
      </Typography>
      )}
      </ListItem>
      {/* Location */}
      <ListItem className={styles['settings-panel__helper-on-hover']}>
      <ListItemText primary={i18n('settings_location')} />
      {/*<FormHelperText>Your country</FormHelperText>*/}
      <FormControl>
      <Autocomplete
      options={COUNTRIES}
      getOptionLabel={(option) => option.name}
      isOptionEqualToValue={(option, value) => option.code === value.code}
      filterOptions={countryFilter}
      value={COUNTRIES.find((c) => c.code === currentSettings.location) ?? null}
      onChange={(_event, option) =>
      updateSetting({
      type: ACTION_TYPE.INPUT_CHANGE,
      name: 'location',
      value: option?.code ?? '',
      })
      }
      size="small"
      className={styles['settings-panel__input']}
      disabled={isPending}
      renderInput={(params) => (
      <TextField {...params} placeholder={i18n('settings_location_placeholder')} />
      )}
      />
      </FormControl>
      </ListItem>
      {/* Language */}
      <ListItem className={styles['settings-panel__helper-on-hover']}>
      <ListItemText primary={i18n('settings_language')} />
      <FormControl>
      <Select
      value={selectedLanguage}
      onChange={handleInputChange}
      name="language"
      size="small"
      className={styles['settings-panel__input']}
      disabled={isPending}
      >
      {AVAILABLE_LOCALES.map((localeCode) => (
      <MenuItem key={localeCode} value={localeCode}>
      {getLanguageName(localeCode)}
      </MenuItem>
      ))}
      </Select>
      </FormControl>
      </ListItem>
      {/* Share anonymous usage data — gates the Google Analytics usage/error
      events (default on; toggling off opts out entirely). */}
      <ListItem className={styles['settings-panel__helper-on-hover']}>
      <ListItemText
      primary={i18n('settings_share_usage_data')}
      secondary={i18n('settings_share_usage_data_desc')}
      />
      <FormControlLabel
      control={
      <Switch
      checked={currentSettings.shareUsageData ?? true}
      onChange={handleSwitchChange}
      name="shareUsageData"
      disabled={isPending}
      />
      }
      labelPlacement="start"
      label=""
      />
      </ListItem>
      </List>
      </AccordionDetails>
      </Accordion>
      <Accordion
      expanded={expanded === 'cache'}
      onChange={handleAccordionChange('cache')}
      disableGutters
      >
      <AccordionSummary
      expandIcon={<ExpandMoreIcon />}
      className={styles['settings-panel__accordion-summary']}
      >
      <Typography variant="body2" fontWeight={500}>
      {i18n('settings_section_cache')}
      </Typography>
      </AccordionSummary>
      <AccordionDetails className={styles['settings-panel__accordion-details']}>
      <List dense component="nav" aria-labelledby="cache-list-subheader">
      {/* Cache Search Results — master switch for supplier caching */}
      <ListItem className={styles['settings-panel__helper-on-hover']}>
      <ListItemText primary={i18n('settings_cache_results')} />
      <FormControlLabel
      control={
      <Switch
      checked={currentSettings.caching?.enabled ?? true}
      onChange={(event) =>
      updateSetting({
      type: ACTION_TYPE.CACHE_CHANGE,
      value: { ...currentSettings.caching, enabled: event.target.checked },
      })
      }
      name="caching"
      disabled={isPending}
      />
      }
      labelPlacement="start"
      label=""
      />
      </ListItem>
      {/* Do Not Cache Empty Results */}
      <ListItem className={styles['settings-panel__helper-on-hover']}>
      <ListItemText primary={i18n('settings_do_not_cache_empty')} />
      <FormControlLabel
      control={
      <Switch
      checked={Boolean(currentSettings.caching?.doNotCacheEmptyResults)}
      onChange={(event) =>
      updateSetting({
      type: ACTION_TYPE.CACHE_CHANGE,
      value: {
      ...currentSettings.caching,
      doNotCacheEmptyResults: event.target.checked,
      },
      })
      }
      name="doNotCacheEmptyResults"
      disabled={isPending}
      />
      }
      labelPlacement="start"
      label=""
      />
      </ListItem>
      {/* Cache TTL (minutes) — 0 disables expiration */}
      <ListItem className={styles['settings-panel__helper-on-hover']}>
      <ListItemText primary={i18n('settings_cache_ttl')} />
      <Box
      sx={{
      display: 'flex',
      flexDirection: 'column',
      alignItems: 'flex-end',
      flexShrink: 0,
      }}
      >
      <FormControl>
      <TextField
      value={currentSettings.caching?.ttlMinutes ?? 0}
      name="cacheTtlMinutes"
      onChange={(event) =>
      updateSetting({
      type: ACTION_TYPE.CACHE_CHANGE,
      value: {
      ...currentSettings.caching,
      ttlMinutes: Math.trunc(Number(event.target.value)),
      },
      })
      }
      type="number"
      variant="outlined"
      size="small"
      className={styles['settings-panel__input']}
      disabled={isPending}
      slotProps={{ htmlInput: { min: 0, step: 1 } }}
      />
      </FormControl>
      {cacheTtlDaysLabel && (
      <Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
      {cacheTtlDaysLabel}
      </Typography>
      )}
      </Box>
      </ListItem>
      {/* Cache stats + clear button */}
      <ListItem
      className={styles['settings-panel__helper-on-hover']}
      sx={{ flexDirection: 'column', alignItems: 'flex-start', rowGap: 0.5 }}
      >
      <Button
      fullWidth
      variant="outlined"
      color="warning"
      size="small"
      onClick={handleClearCache}
      >
      {i18n('settings_clear_cache')}
      </Button>
      {cacheCleared && (
      <Typography variant="caption">{i18n('settings_cache_cleared')}</Typography>
      )}
      {cacheStats && (
      <Typography variant="caption" color="text.secondary">
      {i18n('settings_cache_stats', [
      cacheStats.records.toLocaleString(),
      formatBytes(cacheStats.bytes),
      ])}
      </Typography>
      )}
      </ListItem>
      </List>
      </AccordionDetails>
      </Accordion>
      <Accordion
      expanded={expanded === 'priceHistory'}
      onChange={handleAccordionChange('priceHistory')}
      disableGutters
      >
      <AccordionSummary
      expandIcon={<ExpandMoreIcon />}
      className={styles['settings-panel__accordion-summary']}
      >
      <Typography variant="body2" fontWeight={500}>
      {i18n('settings_section_price_history')}
      </Typography>
      </AccordionSummary>
      <AccordionDetails className={styles['settings-panel__accordion-details']}>
      <List dense component="nav" aria-labelledby="price-history-list-subheader">
      {/* Master toggle — on by default */}
      <ListItem className={styles['settings-panel__helper-on-hover']}>
      <ListItemText
      primary={i18n('settings_track_price_history')}
      secondary={i18n('settings_track_price_history_desc')}
      />
      <FormControlLabel
      control={
      <Switch
      checked={currentSettings.priceTracking?.enabled ?? true}
      onChange={(event) =>
      updateSetting({
      type: ACTION_TYPE.PRICE_TRACKING_CHANGE,
      value: { ...currentSettings.priceTracking, enabled: event.target.checked },
      })
      }
      name="priceTrackingEnabled"
      disabled={isPending}
      />
      }
      labelPlacement="start"
      label=""
      />
      </ListItem>
      {/* Max points per product — 0 means unlimited */}
      <ListItem className={styles['settings-panel__helper-on-hover']}>
      <ListItemText primary={i18n('settings_max_price_points')} />
      <FormControl>
      <TextField
      value={currentSettings.priceTracking?.maxDataPoints ?? 5}
      name="priceTrackingMaxDataPoints"
      onChange={(event) =>
      updateSetting({
      type: ACTION_TYPE.PRICE_TRACKING_CHANGE,
      value: {
      ...currentSettings.priceTracking,
      maxDataPoints: Math.trunc(Number(event.target.value)),
      },
      })
      }
      type="number"
      variant="outlined"
      size="small"
      className={styles['settings-panel__input']}
      disabled={isPending || currentSettings.priceTracking?.enabled === false}
      slotProps={{ htmlInput: { min: 0, step: 1 } }}
      />
      </FormControl>
      </ListItem>
      <ListItem
      className={styles['settings-panel__helper-on-hover']}
      sx={{ flexDirection: 'column', alignItems: 'flex-start', rowGap: 0.5 }}
      >
      <Button
      fullWidth
      variant="outlined"
      color="warning"
      size="small"
      onClick={handleClearPriceHistory}
      >
      {i18n('settings_clear_price_history')}
      </Button>
      {priceHistoryCleared && (
      <Typography variant="caption">{i18n('settings_price_history_cleared')}</Typography>
      )}
      {priceStats && (
      <Typography variant="caption" color="text.secondary">
      {i18n('settings_price_history_stats', [
      priceStats.products.toLocaleString(),
      priceStats.points.toLocaleString(),
      formatBytes(priceStats.bytes),
      ])}
      </Typography>
      )}
      </ListItem>
      </List>
      </AccordionDetails>
      </Accordion>
      <Accordion
      expanded={expanded === 'display'}
      onChange={handleAccordionChange('display')}
      disableGutters
      >
      <AccordionSummary
      expandIcon={<ExpandMoreIcon />}
      className={styles['settings-panel__accordion-summary']}
      >
      <Typography variant="body2" fontWeight={500}>
      {i18n('settings_section_display')}
      </Typography>
      </AccordionSummary>
      <AccordionDetails className={styles['settings-panel__accordion-details']}>
      <List dense component="nav" aria-labelledby="display-list-subheader">
      {/* Font Size */}
      <ListItem className={styles['settings-panel__helper-on-hover']}>
      <ListItemText primary={i18n('settings_font_size')} />
      {/*<FormHelperText>Popup size</FormHelperText>*/}
      <FormControl>
      <ButtonGroup
      variant="contained"
      aria-label={i18n('settings_font_size')}
      onClick={handleButtonClick}
      disabled={isPending}
      >
      <Button
      name="fontSize"
      value="small"
      size="small"
      aria-label={i18n('settings_font_small')}
      title={i18n('settings_font_small')}
      variant={currentSettings.fontSize === 'small' ? 'contained' : 'text'}
      disabled={isPending}
      >
      <TextDecreaseIcon fontSize="small" sx={{ pointerEvents: 'none' }} />
      </Button>
      <Button
      name="fontSize"
      value="medium"
      size="small"
      aria-label={i18n('settings_font_medium')}
      title={i18n('settings_font_medium')}
      variant={currentSettings.fontSize === 'medium' ? 'contained' : 'text'}
      disabled={isPending}
      >
      <TextFormatIcon fontSize="small" sx={{ pointerEvents: 'none' }} />
      </Button>
      <Button
      name="fontSize"
      value="large"
      size="small"
      aria-label={i18n('settings_font_large')}
      title={i18n('settings_font_large')}
      variant={currentSettings.fontSize === 'large' ? 'contained' : 'text'}
      disabled={isPending}
      >
      <TextIncreaseIcon fontSize="small" sx={{ pointerEvents: 'none' }} />
      </Button>
      </ButtonGroup>
      </FormControl>
      </ListItem>
      {/* Open in tab — when on, clicking the toolbar icon opens the full-tab
      view instead of the popup (enforced by the service worker). */}
      <ListItem className={styles['settings-panel__helper-on-hover']}>
      <ListItemText
      primary={i18n('settings_open_in_tab')}
      secondary={i18n('settings_open_in_tab_desc')}
      />
      <FormControlLabel
      control={
      <Switch
      checked={currentSettings.openInTab ?? false}
      onChange={handleSwitchChange}
      name="openInTab"
      disabled={isPending}
      />
      }
      labelPlacement="start"
      label=""
      />
      </ListItem>
      {/* Auto hide empty columns — when on, columns with no data in the
      current result set are hidden automatically (see ResultsTable). */}
      <ListItem className={styles['settings-panel__helper-on-hover']}>
      <ListItemText
      primary={i18n('settings_auto_hide_empty_columns')}
      secondary={i18n('settings_auto_hide_empty_columns_desc')}
      />
      <FormControlLabel
      control={
      <Switch
      checked={currentSettings.autoHideEmptyColumns ?? true}
      onChange={handleSwitchChange}
      name="autoHideEmptyColumns"
      disabled={isPending}
      />
      }
      labelPlacement="start"
      label=""
      />
      </ListItem>

      {/* Group product variants — when on, variants live under a single
      product row; when off, each variant becomes its own table row so
      sorting/filtering apply across all variants (see ResultsTable). */}
      <ListItem className={styles['settings-panel__helper-on-hover']}>
      <ListItemText
      primary={i18n('settings_group_product_variants')}
      secondary={i18n('settings_group_product_variants_desc')}
      />
      <FormControlLabel
      control={
      <Switch
      checked={currentSettings.groupProductVariants ?? true}
      onChange={handleSwitchChange}
      name="groupProductVariants"
      disabled={isPending}
      />
      }
      labelPlacement="start"
      label=""
      />
      </ListItem>
      </List>
      </AccordionDetails>
      </Accordion>
      <Accordion
      expanded={expanded === 'excluded'}
      onChange={handleAccordionChange('excluded')}
      disableGutters
      >
      <AccordionSummary
      expandIcon={<ExpandMoreIcon />}
      className={styles['settings-panel__accordion-summary']}
      >
      <Typography variant="body2" fontWeight={500}>
      {i18n('settings_section_excluded')}
      {excludedCount > 0 && (
      <Typography
      component="span"
      variant="caption"
      color="text.secondary"
      sx={{ ml: 0.5, fontStyle: 'italic' }}
      >
      {i18n('settings_excluded_count', [String(excludedCount)])}
      </Typography>
      )}
      </Typography>
      </AccordionSummary>
      <AccordionDetails className={styles['settings-panel__accordion-details']}>
      {excludedCount === 0 ? (
      <Typography
      variant="caption"
      color="text.secondary"
      className={styles['settings-panel__excluded-empty']}
      >
      {i18n('settings_excluded_empty')}
      </Typography>
      ) : (
      <>
      <List dense disablePadding>
      {excludedEntries.map(([key, entry]) => (
      <ListItem
      key={key}
      divider
      className={styles['settings-panel__excluded-item']}
      secondaryAction={
      <Tooltip title={i18n('settings_remove')}>
      <IconButton
      edge="end"
      size="small"
      onClick={() => handleRemoveExcluded(key)}
      className={styles['settings-panel__excluded-delete-btn']}
      aria-label={i18n('settings_remove_item', [
      entry.title || entry.url || '',
      ])}
      >
      <DeleteIcon className={styles['settings-panel__excluded-delete-icon']} />
      </IconButton>
      </Tooltip>
      }
      >
      <ListItemText
      primary={
      <Link
      href={entry.url}
      target="_blank"
      rel="noopener noreferrer"
      variant="body2"
      className={styles['settings-panel__excluded-link']}
      >
      {entry.title || entry.url}
      </Link>
      }
      secondary={`${entry.supplier} — ${formatTimestamp(entry.excludedAt)}`}
      slotProps={{
      secondary: {
      variant: 'caption',
      className: styles['settings-panel__excluded-secondary-text'],
      },
      }}
      />
      </ListItem>
      ))}
      </List>
      <Box className={styles['settings-panel__excluded-actions']}>
      <Button
      fullWidth
      variant="outlined"
      color="warning"
      size="small"
      onClick={handleClearAllExcluded}
      >
      {i18n('settings_clear_all')}
      </Button>
      </Box>
      </>
      )}
      </AccordionDetails>
      </Accordion>
      {/* Per-supplier enable/disable. Each switch on = supplier enabled; toggling
      off adds it to the disabledSuppliers deny-list, excluding it from every
      search and hiding it from the search filter menu. */}
      <Accordion
      expanded={expanded === 'suppliers'}
      onChange={handleAccordionChange('suppliers')}
      disableGutters
      >
      <AccordionSummary
      expandIcon={<ExpandMoreIcon />}
      className={styles['settings-panel__accordion-summary']}
      >
      <Typography variant="body2" fontWeight={500}>
      {i18n('settings_section_supplier_status')}
      {disabledSupplierCount > 0 && (
      <Typography
      component="span"
      variant="caption"
      color="text.secondary"
      sx={{ ml: 0.5, fontStyle: 'italic' }}
      >
      {i18n('settings_supplier_disabled_count', [String(disabledSupplierCount)])}
      </Typography>
      )}
      </Typography>
      </AccordionSummary>
      <AccordionDetails className={styles['settings-panel__accordion-details']}>
      <Typography
      variant="caption"
      sx={{ px: 2, py: 1, display: 'block', fontStyle: 'italic', color: 'text.secondary' }}
      >
      {i18n('settings_supplier_status_desc')}
      </Typography>
      <List dense sx={{ width: '100%' }}>
      {SUPPLIER_CLASS_NAMES.map((supplierClassName) => (
      <ListItem
      key={supplierClassName}
      className={styles['settings-panel__helper-on-hover']}
      >
      <ListItemText primary={supplierClassName.replace(/^Supplier/, '')} />
      <FormControlLabel
      control={
      <Switch
      checked={
      !(currentSettings.suppliers?.disabled ?? []).includes(supplierClassName)
      }
      onChange={handleSupplierToggle(supplierClassName)}
      name={supplierClassName}
      disabled={isPending}
      />
      }
      labelPlacement="start"
      label=""
      />
      </ListItem>
      ))}
      </List>
      </AccordionDetails>
      </Accordion>
      <Accordion
      expanded={expanded === 'advanced'}
      onChange={handleAccordionChange('advanced')}
      disableGutters
      >
      <AccordionSummary
      expandIcon={<ExpandMoreIcon />}
      className={styles['settings-panel__accordion-summary']}
      >
      <Typography variant="body2" fontWeight={500}>
      {i18n('settings_section_advanced')}
      </Typography>
      </AccordionSummary>
      <AccordionDetails className={styles['settings-panel__accordion-details']}>
      {/* Mirrors the search drawer's single-select filter-input style:
      full-width labeled outlined TextField with italic helper text.
      Using `TextField select` (rather than the horizontal
      ListItem + Select pattern the rest of this panel uses) gives
      long scorer names like `partial_token_similarity_sort_ratio`
      room to render, and keeps the visual consistent with the
      search drawer filters. */}
      {advancedMode && (
      <Box sx={{ p: 1 }}>
      <TextField
      select
      fullWidth
      size="small"
      name="fuzzScorerOverride"
      label={i18n('settings_fuzz_scorer')}
      value={currentSettings.fuzzScorerOverride ?? ''}
      onChange={handleInputChange}
      disabled={isPending}
      helperText={i18n('settings_fuzz_scorer_helper')}
      slotProps={{ formHelperText: { sx: { fontStyle: 'italic' } } }}
      >
      <MenuItem value="">
      <em>{i18n('settings_fuzz_scorer_default')}</em>
      </MenuItem>
      {FUZZ_SCORER_NAMES.map((name) => (
      <MenuItem key={name} value={name}>
      {name}
      </MenuItem>
      ))}
      </TextField>
      </Box>
      )}
      <Box sx={{ p: 1 }}>
      <TextField
      fullWidth
      size="small"
      type="number"
      name="supplierSearchTimeBudgetSec"
      label={i18n('settings_max_search_time')}
      value={currentSettings.supplierSearchTimeBudgetSec ?? ''}
      placeholder={String(search.supplierSearchTimeBudgetSec)}
      onChange={handleInputChange}
      disabled={isPending}
      helperText={i18n('settings_max_search_time_helper', [
      String(search.supplierSearchTimeBudgetSec),
      ])}
      slotProps={{
      formHelperText: { sx: { fontStyle: 'italic' } },
      htmlInput: { min: 0, step: 1 },
      }}
      />
      </Box>
      {advancedMode && (
      <ListItem className={styles['settings-panel__helper-on-hover']}>
      <ListItemText
      primary={i18n('settings_disable_fuzzy')}
      secondary={i18n('settings_disable_fuzzy_desc')}
      slotProps={{ secondary: { sx: { fontStyle: 'italic' } } }}
      />
      <FormControlLabel
      control={
      <Switch
      checked={currentSettings.fuzzyFilteringDisabled ?? false}
      onChange={handleSwitchChange}
      name="fuzzyFilteringDisabled"
      disabled={isPending}
      />
      }
      labelPlacement="start"
      label=""
      />
      </ListItem>
      )}
      </AccordionDetails>
      </Accordion>
      <Accordion
      expanded={expanded === 'actions'}
      onChange={handleAccordionChange('actions')}
      disableGutters
      >
      <AccordionSummary
      expandIcon={<ExpandMoreIcon />}
      className={styles['settings-panel__accordion-summary']}
      >
      <Typography variant="body2" fontWeight={500}>
      {i18n('settings_section_actions')}
      </Typography>
      </AccordionSummary>
      <AccordionDetails className={styles['settings-panel__accordion-details--actions']}>
      <Stack direction="column" spacing={2}>
      <Button
      fullWidth
      variant="outlined"
      color="warning"
      size="small"
      onClick={handleRestoreDefaults}
      disabled={isPending}
      >
      {i18n('settings_restore_defaults')}
      </Button>
      <Button
      fullWidth
      variant="outlined"
      size="small"
      startIcon={<BugReportIcon />}
      onClick={() => void showReportDialog()}
      >
      {i18n('settings_report_bug')}
      </Button>
      </Stack>
      </AccordionDetails>
      </Accordion>
      </Box>
      );
      }