diff --git a/website/modules/asset/ui/src/scss/_cases.scss b/website/modules/asset/ui/src/scss/_cases.scss index 65de7094..151f1ca0 100644 --- a/website/modules/asset/ui/src/scss/_cases.scss +++ b/website/modules/asset/ui/src/scss/_cases.scss @@ -78,9 +78,110 @@ } } +// New search bar (separate from .cs_search-form to avoid conflicts) +.cs_search-bar-wrapper { + width: 100%; + margin-bottom: 20px; + @include breakpoint-medium { + margin-bottom: 0; + } +} + +.cs_search-bar-form { + width: 100%; + max-width: 1200px; + margin: 0 auto; +} + +.cs_search-bar-input-wrapper { + position: relative; + display: flex; + align-items: center; + width: 100%; + height: 56px; + background: $white; + border: 1px solid rgba($gray-200, 0.4); + box-sizing: border-box; + transition: border-color 0.2s ease; + + &:focus-within { + outline: 0; + border: none; + border-bottom: 1px solid rgba($gray-200, 0.4); + + .cs_search-bar-icon { + display: none; + } + } +} + +.cs_search-bar-icon { + position: absolute; + left: 1rem; + width: 24px; + height: 24px; + pointer-events: none; + z-index: 1; +} + +.cs_search-bar-input { + flex: 1; + width: 100%; + height: 100%; + padding: 0 1rem 0 3rem; + border: none; + background: transparent; + font-size: 14px; + font-weight: $font-weight-medium; + color: $gray-500; + outline: none; + box-shadow: none; + + &::placeholder { + color: $gray-300; + } + + &:focus { + border: none; + outline: none; + box-shadow: none; + } +} + +.cs_search-bar-clear { + display: none; + position: absolute; + right: 1rem; + width: 16px; + height: 16px; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + z-index: 2; + align-items: center; + justify-content: center; + + img { + width: 16px; + height: 16px; + } + + &:hover { + opacity: 0.7; + } +} + +.cs_search-bar-input:not(:placeholder-shown) ~ .cs_search-bar-clear, +.cs_search-bar-input:focus ~ .cs_search-bar-clear, +.cs_search-bar-clear--visible { + display: flex; +} + // Tags styling .tags-filter { border: 1px solid $gray-border; + flex-shrink: 0; font-style: $font-style-normal; max-width: 262px; width: 100%; @@ -468,6 +569,7 @@ align-items: flex-start; justify-content: flex-start; top: $desktop-header-height; + margin-top: 20px; } } @@ -630,6 +732,7 @@ grid-template-columns: 1fr; gap: 32px; margin: 0 auto; + min-width: 0; opacity: 0; transform: translateY(30px); filter: blur(2px); @@ -1190,6 +1293,11 @@ filter: none; } + .cs_search-bar-input-wrapper, + .cs_search-bar-clear { + transition: none; + } + .tag-item { transition: none; diff --git a/website/modules/case-studies-page/index.js b/website/modules/case-studies-page/index.js index 29daa7f8..d3a070fe 100644 --- a/website/modules/case-studies-page/index.js +++ b/website/modules/case-studies-page/index.js @@ -1,8 +1,60 @@ const mainWidgets = require('../../lib/mainWidgets'); -const TagCountService = require('./services/TagCountService'); const NavigationService = require('./services/NavigationService'); +const SearchService = require('./services/SearchService'); +const TagCountService = require('./services/TagCountService'); const UrlService = require('./services/UrlService'); +const buildIndexQuery = function (self, req) { + const queryParams = { ...req.query }; + const searchTerm = SearchService.getSearchTerm(queryParams); + delete queryParams.search; + + const query = self.pieces + .find(req, {}) + .applyBuildersSafely(queryParams) + .perPage(self.perPage); + self.filterByIndexPage(query, req.data.page); + + const searchCondition = SearchService.buildSearchCondition(searchTerm); + if (searchCondition) { + query.and(searchCondition); + } + return query; +}; + +const runSetupIndexData = async function (self, req) { + try { + const tagCounts = await TagCountService.calculateTagCounts( + req, + self.apos.modules, + self.options, + ); + UrlService.attachIndexData(req, tagCounts); + } catch (error) { + self.apos.util.error('Error calculating tag counts:', error); + UrlService.attachIndexData(req, { + industry: {}, + stack: {}, + caseStudyType: {}, + partner: {}, + }); + } +}; + +const runSetupShowData = async function (self, req) { + try { + const navigation = await NavigationService.getNavigationDataForPage( + req, + self.apos, + self, + ); + UrlService.attachShowData(req, navigation); + } catch (error) { + self.apos.util.error('Error calculating navigation data:', error); + UrlService.attachShowData(req, { prev: null, next: null }); + } +}; + module.exports = { extend: '@apostrophecms/piece-page-type', options: { @@ -35,48 +87,33 @@ module.exports = { }, init(self) { + const superBeforeIndex = self.beforeIndex; self.beforeIndex = async (req) => { + if (superBeforeIndex) { + await superBeforeIndex(req); + } await self.setupIndexData(req); }; + const superBeforeShow = self.beforeShow; self.beforeShow = async (req) => { + if (superBeforeShow) { + await superBeforeShow(req); + } await self.setupShowData(req); }; }, methods(self) { return { + indexQuery(req) { + return buildIndexQuery(self, req); + }, async setupIndexData(req) { - try { - const tagCounts = await TagCountService.calculateTagCounts( - req, - self.apos.modules, - self.options, - ); - UrlService.attachIndexData(req, tagCounts); - } catch (error) { - self.apos.util.error('Error calculating tag counts:', error); - UrlService.attachIndexData(req, { - industry: {}, - stack: {}, - caseStudyType: {}, - partner: {}, - }); - } + return await runSetupIndexData(self, req); }, - async setupShowData(req) { - try { - const navigation = await NavigationService.getNavigationDataForPage( - req, - self.apos, - self, - ); - UrlService.attachShowData(req, navigation); - } catch (error) { - self.apos.util.error('Error calculating navigation data:', error); - UrlService.attachShowData(req, { prev: null, next: null }); - } + return await runSetupShowData(self, req); }, }; }, diff --git a/website/modules/case-studies-page/services/NavigationService.js b/website/modules/case-studies-page/services/NavigationService.js index 28e2f26e..74af551c 100644 --- a/website/modules/case-studies-page/services/NavigationService.js +++ b/website/modules/case-studies-page/services/NavigationService.js @@ -1,3 +1,5 @@ +const SearchService = require('./SearchService'); + /** * NavigationService - Single Responsibility: Case study navigation * @@ -68,6 +70,22 @@ class NavigationService { ); } + /** + * Applies search filter to query when search param is present + * Uses SearchService for safe regex (ReDoS prevention) and array handling + * @param {Object} filteredQuery - Query object + * @param {Object} req - Request object + * @returns {Object} Modified query + */ + static applySearchFilter(filteredQuery, req) { + const searchTerm = SearchService.getSearchTerm(req.query || {}); + const searchCondition = SearchService.buildSearchCondition(searchTerm); + if (!searchCondition) { + return filteredQuery; + } + return filteredQuery.and(searchCondition); + } + /** * Applies filters to a query based on request parameters * @param {Object} query - ApostropheCMS query object @@ -115,7 +133,7 @@ class NavigationService { }); } } - return filteredQuery; + return NavigationService.applySearchFilter(filteredQuery, req); } /** diff --git a/website/modules/case-studies-page/services/SearchService.js b/website/modules/case-studies-page/services/SearchService.js new file mode 100644 index 00000000..3062c090 --- /dev/null +++ b/website/modules/case-studies-page/services/SearchService.js @@ -0,0 +1,74 @@ +/** + * SearchService - Shared search term normalization and safe regex building + * + * Used by case-studies-page index query and NavigationService so search + * behavior and escaping stay consistent and safe (ReDoS prevention). + */ + +const REGEX_ESCAPE = /[$()*+.?[\\\]^{|}]/gu; + +const MAX_SEARCH_TERM_LENGTH = 200; + +/** + * Normalizes search param from query (handles missing, array, non-string) + * @param {Object} queryParams - Request query object (e.g. req.query) + * @returns {string} Trimmed search string, or empty string + */ +const getSearchTerm = function (queryParams) { + if (!queryParams || !queryParams.search) { + return ''; + } + const raw = queryParams.search; + let value = raw; + if (Array.isArray(raw)) { + [value] = raw; + } + if (typeof value !== 'string') { + return ''; + } + return value.trim(); +}; + +/** + * Builds a safe MongoDB regex pattern from search term (escape + word match) + * Search term is capped at MAX_SEARCH_TERM_LENGTH to avoid pathologically long patterns + * @param {string} searchTerm - User search string + * @returns {string|null} Pattern for $regex, or null if no search + */ +const buildSearchRegexPattern = function (searchTerm) { + if (!searchTerm || !searchTerm.trim()) { + return null; + } + const trimmed = searchTerm.trim(); + if (!trimmed) { + return null; + } + let capped = trimmed; + if (trimmed.length > MAX_SEARCH_TERM_LENGTH) { + capped = trimmed.slice(0, MAX_SEARCH_TERM_LENGTH); + } + const escaped = capped.replace(REGEX_ESCAPE, '\\$&'); + return escaped.split(/\s+/u).join('.*'); +}; + +/** + * Builds MongoDB $or condition for case study search (single source of truth for searchable fields) + * @param {string} searchTerm - User search string + * @returns {Object|null} Condition to pass to query.and(), or null if no search + */ +const buildSearchCondition = function (searchTerm) { + const regexPattern = buildSearchRegexPattern(searchTerm); + if (!regexPattern) { + return null; + } + const regexOpts = { $regex: regexPattern, $options: 'i' }; + return { + $or: [{ title: regexOpts }, { portfolioTitle: regexOpts }], + }; +}; + +module.exports = { + buildSearchCondition, + buildSearchRegexPattern, + getSearchTerm, +}; diff --git a/website/modules/case-studies-page/services/UrlService.js b/website/modules/case-studies-page/services/UrlService.js index 4376be3e..f18963a9 100644 --- a/website/modules/case-studies-page/services/UrlService.js +++ b/website/modules/case-studies-page/services/UrlService.js @@ -74,6 +74,31 @@ class UrlService { return [value]; } + /** + * Filter param keys that must be arrays when present (for safe iteration in + * templates). + */ + static get filterParamKeys() { + return ['industry', 'stack', 'caseStudyType', 'partner']; + } + + /** + * Returns a copy of query with filter params normalized to arrays so + * templates can safely iterate (avoids single-string params iterating as + * characters). + * @param {Object} query - Raw request query object + * @returns {Object} New object with filter params as arrays when present + */ + static normalizeFilterParams(query) { + const result = { ...query }; + UrlService.filterParamKeys.forEach((key) => { + if (result[key] !== null && result[key] !== undefined) { + result[key] = UrlService.ensureArray(result[key]); + } + }); + return result; + } + /** * Attaches index page data to request * @param {Object} req - Request object @@ -85,6 +110,7 @@ class UrlService { reqCopy.data = {}; } reqCopy.data.tagCounts = tagCounts; + reqCopy.data.query = UrlService.normalizeFilterParams(req.query || {}); // Set default visible tags count reqCopy.data.defaultVisibleTagsCount = 5; reqCopy.data.buildCaseStudyUrl = (caseStudyUrl) => @@ -102,7 +128,7 @@ class UrlService { reqCopy.data = {}; } - const queryParams = req.query || {}; + const queryParams = UrlService.normalizeFilterParams(req.query || {}); // Build complete URLs server-side to avoid template encoding issues reqCopy.data.prev = navigation.prev; diff --git a/website/modules/case-studies-page/views/index.html b/website/modules/case-studies-page/views/index.html index 23738a1d..dcf98a91 100644 --- a/website/modules/case-studies-page/views/index.html +++ b/website/modules/case-studies-page/views/index.html @@ -1,9 +1,12 @@ -{# modules/case-studies-page/views/index.new.html #} {% extends "layout.html" %} -{% import '@apostrophecms/pager:macros.html' as pager with context %} {% block -main %} +{# modules/case-studies-page/views/index.html #} +{% extends "layout.html" %} +{% import '@apostrophecms/pager:macros.html' as pager with context %} +{% block main %}
{{ data.totalPieces }} Ite{% if data.totalPieces == 1 %}m{% else %}ms{% endif %} Found @@ -32,7 +87,7 @@
+ {% endblock main %} diff --git a/website/public/images/search.svg b/website/public/images/search.svg new file mode 100644 index 00000000..14e84602 --- /dev/null +++ b/website/public/images/search.svg @@ -0,0 +1,3 @@ + diff --git a/website/public/js/modules/case-studies-page/search-handler.js b/website/public/js/modules/case-studies-page/search-handler.js new file mode 100644 index 00000000..6e87281a --- /dev/null +++ b/website/public/js/modules/case-studies-page/search-handler.js @@ -0,0 +1,131 @@ +/** + * Case Studies Search Handler + * Triggers search on Enter (form submit) and clear button click + */ + +(function () { + 'use strict'; + + // Build URL with search and existing filters + function buildSearchUrl(searchValue) { + const url = new URL(window.location.href); + const params = new URLSearchParams(url.search); + + // Update or remove search parameter + if (searchValue && searchValue.trim()) { + params.set('search', searchValue.trim()); + } else { + params.delete('search'); + } + + // Remove page parameter when searching (reset to page 1) + params.delete('page'); + + // Build new URL + const newSearch = params.toString(); + const newUrl = + url.pathname + (newSearch ? '?' + newSearch : '') + url.hash; + + return newUrl; + } + + // Navigate to URL with updated search (single history entry; Back works as expected) + function performSearch(searchValue) { + const newUrl = buildSearchUrl(searchValue); + window.location.assign(newUrl); + } + + const VISIBLE_CLASS = 'cs_search-bar-clear--visible'; + + // Sync clear button visibility via CSS class (no inline styles; CSS rules control display) + function updateClearButtonVisibility(searchInput, clearButton) { + if (!searchInput || !clearButton) { + return; + } + const hasValue = searchInput.value && searchInput.value.trim(); + if (hasValue) { + clearButton.classList.add(VISIBLE_CLASS); + } else { + clearButton.classList.remove(VISIBLE_CLASS); + } + } + + // Handle clear button click + function handleClearClick(event) { + event.preventDefault(); + event.stopPropagation(); + + const searchInput = document.getElementById('case-studies-search'); + const clearButton = event.target.closest('.cs_search-bar-clear'); + if (!searchInput) { + return; + } + const hadValue = searchInput.value && searchInput.value.trim(); + searchInput.value = ''; + updateClearButtonVisibility(searchInput, clearButton); + if (hadValue) { + performSearch(''); + } + } + + // Handle form submission (Enter key) – triggers search + function handleFormSubmit(event) { + event.preventDefault(); + const searchInput = event.target.querySelector('.cs_search-bar-input'); + if (searchInput) { + performSearch(searchInput.value); + } + } + + function handleSearchFocus(event) { + event.target.setAttribute('placeholder', 'Try a title or description'); + } + + function handleSearchBlur(event) { + if (!event.target.value) { + event.target.setAttribute('placeholder', 'Search case studies'); + } + } + + // Initialize search handler + function initSearchHandler() { + const searchForm = document.querySelector('.cs_search-bar-form'); + const searchInput = document.getElementById('case-studies-search'); + const clearButton = document.querySelector('.cs_search-bar-clear'); + + if (!searchForm || !searchInput) { + return; + } + + function handleSearchInput(event) { + updateClearButtonVisibility(event.target, clearButton); + } + + // Add input event listener (handler closes over clearButton; no live query per keystroke) + searchInput.addEventListener('input', handleSearchInput); + + // Add form submit handler + searchForm.addEventListener('submit', handleFormSubmit); + + // Add clear button handler + if (clearButton) { + clearButton.addEventListener('click', handleClearClick); + } + + // Update placeholder on focus/blur + searchInput.addEventListener('focus', handleSearchFocus); + searchInput.addEventListener('blur', handleSearchBlur); + + // Initial clear button visibility + if (clearButton) { + updateClearButtonVisibility(searchInput, clearButton); + } + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initSearchHandler); + } else { + initSearchHandler(); + } +})();