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
108 changes: 108 additions & 0 deletions website/modules/asset/ui/src/scss/_cases.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down Expand Up @@ -468,6 +569,7 @@
align-items: flex-start;
justify-content: flex-start;
top: $desktop-header-height;
margin-top: 20px;
}
}

Expand Down Expand Up @@ -630,6 +732,7 @@
grid-template-columns: 1fr;
gap: 32px;
margin: 0 auto;
min-width: 0;
opacity: 0;
transform: translateY(30px);
filter: blur(2px);
Expand Down Expand Up @@ -1190,6 +1293,11 @@
filter: none;
}

.cs_search-bar-input-wrapper,
.cs_search-bar-clear {
transition: none;
}

.tag-item {
transition: none;

Expand Down
95 changes: 66 additions & 29 deletions website/modules/case-studies-page/index.js
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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);
},
};
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const SearchService = require('./SearchService');

/**
* NavigationService - Single Responsibility: Case study navigation
*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -115,7 +133,7 @@ class NavigationService {
});
}
}
return filteredQuery;
return NavigationService.applySearchFilter(filteredQuery, req);
}

/**
Expand Down
74 changes: 74 additions & 0 deletions website/modules/case-studies-page/services/SearchService.js
Original file line number Diff line number Diff line change
@@ -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,
};
Loading