diff --git a/website/modules/asset/ui/src/scss/_animation.scss b/website/modules/asset/ui/src/scss/_animation.scss index e7525f1e..1d4cef9d 100644 --- a/website/modules/asset/ui/src/scss/_animation.scss +++ b/website/modules/asset/ui/src/scss/_animation.scss @@ -55,20 +55,3 @@ filter: blur(0); } } - -@keyframes tagSlideIn { - 0% { - opacity: 0; - max-height: 0; - transform: translateY(-10px); - } - 50% { - opacity: 0.5; - transform: translateY(-5px); - } - 100% { - opacity: 1; - max-height: 50px; - transform: translateY(0); - } -} diff --git a/website/modules/asset/ui/src/scss/_cases.scss b/website/modules/asset/ui/src/scss/_cases.scss index c946f639..6eb5245a 100644 --- a/website/modules/asset/ui/src/scss/_cases.scss +++ b/website/modules/asset/ui/src/scss/_cases.scss @@ -1021,6 +1021,144 @@ } } +// Case Study Navigation +.cs_nav-container { + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.cs_back-link { + font-weight: $font-weight-300; + font-size: 11px; + line-height: 120%; + text-decoration: none; + display: flex; + flex-direction: row; + align-items: center; + padding: 0px; + gap: 8px; + color: $gray-300; + @include breakpoint-medium { + gap: 12px; + } + &:hover { + text-decoration: underline; + cursor: pointer; + } + + .cs_back-arrow { + transition: opacity 0.2s ease; + flex-shrink: 0; + + &--desktop { + display: none; + + @include breakpoint-medium { + display: block; + } + } + + &--mobile { + display: block; + + @include breakpoint-medium { + display: none; + } + } + } +} + +.cs_navigation { + display: flex; + flex-direction: row; + align-items: center; + padding: 0; + gap: 20px; + @include breakpoint-medium { + gap: 44px; + } +} + +.cs_nav-item { + display: flex; + flex-direction: row; + align-items: center; + padding: 0; + gap: 12px; + height: 13px; + flex: none; + flex-grow: 0; +} + +.cs_nav-link { + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; + font-weight: $font-weight-300; + font-size: 11px; + line-height: 120%; + text-decoration: none; + transition: all 0.2s ease; + + &--active { + color: $gray-500; + + &:hover { + text-decoration: underline; + + .cs_nav-arrow[src*='arrow-left'] { + opacity: 0.7; + transform: translateX(2px); + } + + .cs_nav-arrow[src*='arrow-right'] { + opacity: 0.7; + transform: translateX(-2px); + } + } + + .cs_nav-arrow { + opacity: 1; + } + } + + &--disabled { + color: $gray-300; + cursor: default; + + .cs_nav-arrow { + opacity: 0.4; + } + } +} + +.cs_nav-arrow { + transition: all 0.2s ease; + flex-shrink: 0; +} + +.cs_nav-text { + display: none; + + @include breakpoint-medium { + display: block; + } +} + +// Keyframes for smooth animations +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + // Animation for reduced motion users (accessibility) @media (prefers-reduced-motion: reduce) { .cs_grid, diff --git a/website/modules/case-studies-page/index.js b/website/modules/case-studies-page/index.js index bb49968f..f0f0241e 100644 --- a/website/modules/case-studies-page/index.js +++ b/website/modules/case-studies-page/index.js @@ -1,51 +1,7 @@ const mainWidgets = require('../../lib/mainWidgets'); -const DEFAULT_VISIBLE_TAGS_COUNT = 5; - -// Helper utility functions for tag counting -const tagCountHelpers = { - createTagMap(casesTags) { - const tagMap = {}; - casesTags.forEach((tag) => { - tagMap[tag.aposDocId] = tag.slug; - }); - return tagMap; - }, - - countTagsOfType(tagIds, tagMap, countsForType) { - if (!tagIds?.length) { - return; - } - tagIds.forEach((tagId) => { - const tagSlug = tagMap[tagId]; - if (tagSlug && !countsForType[tagSlug]) { - countsForType[tagSlug] = 0; - } - if (tagSlug) { - countsForType[tagSlug] += 1; - } - }); - }, - - async fetchCaseStudiesAndTags(req, aposModules, options) { - const caseStudies = await aposModules[options.pieces].find(req).toArray(); - const casesTags = await aposModules['cases-tags'].find(req).toArray(); - return [caseStudies, casesTags]; - }, - - processCaseStudies(caseStudies, tagMap, tagCounts) { - caseStudies.forEach((study) => { - this.countTagsOfType(study.industryIds, tagMap, tagCounts.industry); - - this.countTagsOfType(study.stackIds, tagMap, tagCounts.stack); - - this.countTagsOfType( - study.caseStudyTypeIds, - tagMap, - tagCounts.caseStudyType, - ); - }); - }, -}; +const TagCountService = require('./services/TagCountService'); +const NavigationService = require('./services/NavigationService'); +const UrlService = require('./services/UrlService'); module.exports = { extend: '@apostrophecms/piece-page-type', @@ -79,53 +35,46 @@ module.exports = { init(self) { self.beforeIndex = async (req) => { - try { - const tagCounts = await self.calculateTagCounts(req); - - const reqCopy = req; - if (!reqCopy.data) { - reqCopy.data = {}; - } - reqCopy.data.tagCounts = tagCounts; - reqCopy.data.defaultVisibleTagsCount = DEFAULT_VISIBLE_TAGS_COUNT; - } catch (error) { - self.apos.util.error('Error calculating tag counts:', error); + await self.setupIndexData(req); + }; - const reqCopy = req; - if (!reqCopy.data) { - reqCopy.data = {}; - } - reqCopy.data.tagCounts = { - industry: {}, - stack: {}, - caseStudyType: {}, - }; - reqCopy.data.defaultVisibleTagsCount = DEFAULT_VISIBLE_TAGS_COUNT; - } + self.beforeShow = async (req) => { + await self.setupShowData(req); }; }, methods(self) { return { - async calculateTagCounts(req) { - const tagCounts = { - industry: {}, - stack: {}, - caseStudyType: {}, - }; - - const [caseStudies, casesTags] = - await tagCountHelpers.fetchCaseStudiesAndTags( + 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: {}, + }); + } + }, - const tagMap = tagCountHelpers.createTagMap(casesTags); - - tagCountHelpers.processCaseStudies(caseStudies, tagMap, tagCounts); - - return tagCounts; + 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 }); + } }, }; }, diff --git a/website/modules/case-studies-page/services/NavigationService.js b/website/modules/case-studies-page/services/NavigationService.js new file mode 100644 index 00000000..d0871f37 --- /dev/null +++ b/website/modules/case-studies-page/services/NavigationService.js @@ -0,0 +1,180 @@ +/** + * NavigationService - Single Responsibility: Case study navigation + * + * Handles all operations related to case study navigation. + * This service is responsible for: + * - Fetching filtered case studies for navigation + * - Finding current case study position in filtered list + * - Calculating previous and next case studies + */ +class NavigationService { + /** + * Converts tag slugs to IDs + * @param {Object} apos - ApostropheCMS instance + * @param {Object} req - Request object + * @param {Array} slugs - Array of tag slugs + * @returns {Promise} Promise resolving to array of tag IDs + */ + static async convertSlugsToIds(apos, req, slugs) { + const tagPromises = slugs.map(async (slug) => { + const results = await apos.modules['cases-tags'] + .find(req, { slug }) + .toArray(); + if (results.length > 0) { + return results[0]; + } + return null; + }); + const tags = await Promise.all(tagPromises); + return tags.filter((tag) => tag).map((tag) => tag.aposDocId); + } + + /** + * Applies filters to a query based on request parameters + * @param {Object} query - ApostropheCMS query object + * @param {Object} req - Request object + * @param {Object} apos - ApostropheCMS instance + * @returns {Promise} Promise resolving to modified query object + */ + static async applyFiltersToQuery(query, req, apos) { + let filteredQuery = query; + + if (req.query.industry && req.query.industry.length > 0) { + const industryIds = await NavigationService.convertSlugsToIds( + apos, + req, + req.query.industry, + ); + if (industryIds.length > 0) { + filteredQuery = filteredQuery.and({ + industryIds: { $in: industryIds }, + }); + } + } + + if (req.query.stack && req.query.stack.length > 0) { + const stackIds = await NavigationService.convertSlugsToIds( + apos, + req, + req.query.stack, + ); + if (stackIds.length > 0) { + filteredQuery = filteredQuery.and({ stackIds: { $in: stackIds } }); + } + } + + if (req.query.caseStudyType && req.query.caseStudyType.length > 0) { + const caseStudyTypeIds = await NavigationService.convertSlugsToIds( + apos, + req, + req.query.caseStudyType, + ); + if (caseStudyTypeIds.length > 0) { + filteredQuery = filteredQuery.and({ + caseStudyTypeIds: { $in: caseStudyTypeIds }, + }); + } + } + + return filteredQuery; + } + + /** + * Gets all case studies with optional filtering applied + * @param {Object} req - ApostropheCMS request object + * @param {Object} apos - ApostropheCMS instance + * @param {boolean} applyFilters - Whether to apply current filters + * @param {Object} pageModule - Page module for filter application + * @returns {Promise} Promise resolving to array of case study objects + */ + static async getAllCaseStudies( + req, + apos, + applyFilters = false, + pageModule = null, + ) { + let query = apos.modules['case-studies'].find(req); + + if (applyFilters && req.query) { + query = await NavigationService.applyFiltersToQuery(query, req, apos); + } + + return await query.sort({ updatedAt: -1 }).toArray(); + } + + /** + * Finds the index of the current case study in the filtered list + * @param {Array} allCaseStudies - Array of all case studies + * @param {Object} currentPiece - Current case study object + * @returns {number} Index of current case study (-1 if not found) + */ + static findCurrentIndex(allCaseStudies, currentPiece) { + // eslint-disable-next-line no-underscore-dangle + return allCaseStudies.findIndex((study) => study._id === currentPiece._id); + } + + /** + * Calculates previous and next case studies for navigation + * @param {Array} allCaseStudies - Array of all case studies + * @param {number} currentIndex - Index of current case study + * @returns {Object} Object with prev and next case studies + */ + static calculatePrevNext(allCaseStudies, currentIndex) { + let prev = null; + let next = null; + + if (currentIndex > 0) { + prev = allCaseStudies[currentIndex - 1]; + } + + if (currentIndex < allCaseStudies.length - 1) { + next = allCaseStudies[currentIndex + 1]; + } + + return { prev, next }; + } + + /** + * Gets complete navigation data for a case study + * @param {Object} req - ApostropheCMS request object + * @param {Object} apos - ApostropheCMS instance + * @param {Object} pageModule - Page module for filter application + * @param {Object} currentPiece - Current case study object + * @returns {Promise} Promise resolving to navigation data with prev and next + */ + static async getNavigationData(req, apos, pageModule, currentPiece) { + const allCaseStudies = await NavigationService.getAllCaseStudies( + req, + apos, + true, + pageModule, + ); + + const currentIndex = NavigationService.findCurrentIndex( + allCaseStudies, + currentPiece, + ); + + return NavigationService.calculatePrevNext(allCaseStudies, currentIndex); + } + + /** + * Gets navigation data for page module + * @param {Object} req - ApostropheCMS request object + * @param {Object} apos - ApostropheCMS instance + * @param {Object} pageModule - Page module for filter application + * @returns {Promise} Promise resolving to navigation data with prev and next + */ + static async getNavigationDataForPage(req, apos, pageModule) { + const currentPiece = req.data.piece; + + return await NavigationService.getNavigationData( + req, + apos, + pageModule, + currentPiece, + ); + } +} + +module.exports = NavigationService; diff --git a/website/modules/case-studies-page/services/TagCountService.js b/website/modules/case-studies-page/services/TagCountService.js new file mode 100644 index 00000000..9494f3d4 --- /dev/null +++ b/website/modules/case-studies-page/services/TagCountService.js @@ -0,0 +1,107 @@ +/** + * TagCountService - Single Responsibility: Tag counting and management + * + * Handles all operations related to counting and processing case study tags. + * This service is responsible for: + * - Creating tag maps from tag data + * - Counting tags by type (industry, stack, case study type) + * - Fetching and processing case studies and tags data + */ +class TagCountService { + /** + * Creates a mapping from tag IDs to tag slugs + * @param {Array} casesTags - Array of tag objects + * @returns {Object} Map of tag ID to slug + */ + static createTagMap(casesTags) { + const tagMap = {}; + casesTags.forEach((tag) => { + tagMap[tag.aposDocId] = tag.slug; + }); + return tagMap; + } + + /** + * Counts tags of a specific type and updates the counts object + * @param {Array} tagIds - Array of tag IDs to count + * @param {Object} tagMap - Map of tag ID to slug + * @param {Object} countsForType - Object to store counts for this type + */ + static countTagsOfType(tagIds, tagMap, countsForType) { + if (!tagIds?.length) { + return; + } + tagIds.forEach((tagId) => { + const tagSlug = tagMap[tagId]; + if (tagSlug && !countsForType[tagSlug]) { + countsForType[tagSlug] = 0; + } + if (tagSlug) { + countsForType[tagSlug] += 1; + } + }); + } + + /** + * Fetches case studies and tags data from the database + * @param {Object} req - ApostropheCMS request object + * @param {Object} aposModules - ApostropheCMS modules + * @param {Object} options - Module options + * @returns {Promise} Promise resolving to [caseStudies, casesTags] arrays + */ + static async fetchCaseStudiesAndTags(req, aposModules, options) { + const caseStudies = await aposModules[options.pieces].find(req).toArray(); + const casesTags = await aposModules['cases-tags'].find(req).toArray(); + return [caseStudies, casesTags]; + } + + /** + * Processes case studies to count tags by type + * @param {Array} caseStudies - Array of case study objects + * @param {Object} tagMap - Map of tag ID to slug + * @param {Object} tagCounts - Object to store all tag counts + */ + static processCaseStudies(caseStudies, tagMap, tagCounts) { + caseStudies.forEach((study) => { + TagCountService.countTagsOfType( + study.industryIds, + tagMap, + tagCounts.industry, + ); + + TagCountService.countTagsOfType(study.stackIds, tagMap, tagCounts.stack); + + TagCountService.countTagsOfType( + study.caseStudyTypeIds, + tagMap, + tagCounts.caseStudyType, + ); + }); + } + + /** + * Calculates tag counts for case studies + * @param {Object} req - ApostropheCMS request object + * @param {Object} aposModules - ApostropheCMS modules + * @param {Object} options - Module options + * @returns {Promise} Tag counts by type + */ + static async calculateTagCounts(req, aposModules, options) { + const tagCounts = { + industry: {}, + stack: {}, + caseStudyType: {}, + }; + + const [caseStudies, casesTags] = + await TagCountService.fetchCaseStudiesAndTags(req, aposModules, options); + + const tagMap = TagCountService.createTagMap(casesTags); + + TagCountService.processCaseStudies(caseStudies, tagMap, tagCounts); + + return tagCounts; + } +} + +module.exports = TagCountService; diff --git a/website/modules/case-studies-page/services/UrlService.js b/website/modules/case-studies-page/services/UrlService.js new file mode 100644 index 00000000..00a6c59c --- /dev/null +++ b/website/modules/case-studies-page/services/UrlService.js @@ -0,0 +1,123 @@ +/** + * UrlService - Single Responsibility: URL building and management + * + * Handles all operations related to building URLs with query parameters. + * This service is responsible for: + * - Building case study URLs with preserved query parameters + * - Properly encoding URL parameters + * - Handling both single values and arrays in query parameters + */ +class UrlService { + /** + * Builds a case study URL with current query parameters preserved + * @param {string} baseUrl - The base URL of the case study + * @param {Object} query - Current query parameters from the request + * @returns {string} Complete URL with query parameters + */ + static buildCaseStudyUrl(baseUrl, query = {}) { + const params = new URLSearchParams(); + + // Add industry parameters + if (query.industry) { + const industries = UrlService.ensureArray(query.industry); + industries.forEach((industry, index) => { + params.append(`industry[${index}]`, industry); + }); + } + + // Add stack parameters + if (query.stack) { + const stacks = UrlService.ensureArray(query.stack); + stacks.forEach((stack, index) => { + params.append(`stack[${index}]`, stack); + }); + } + + // Add case study type parameters + if (query.caseStudyType) { + const caseStudyTypes = UrlService.ensureArray(query.caseStudyType); + caseStudyTypes.forEach((caseStudyType, index) => { + params.append(`caseStudyType[${index}]`, caseStudyType); + }); + } + + // Add search parameter + if (query.search) { + params.append('search', query.search); + } + + // Build final URL + const queryString = params.toString(); + if (queryString) { + return `${baseUrl}?${queryString}`; + } + return baseUrl; + } + + /** + * Ensures a value is an array + * @param {*} value - Value that could be string or array + * @returns {Array} Array version of the value + */ + static ensureArray(value) { + if (Array.isArray(value)) { + return value; + } + return [value]; + } + + /** + * Attaches index page data to request + * @param {Object} req - Request object + * @param {Object} tagCounts - Tag counts data + */ + static attachIndexData(req, tagCounts) { + const reqCopy = req; + if (!reqCopy.data) { + reqCopy.data = {}; + } + reqCopy.data.tagCounts = tagCounts; + reqCopy.data.buildCaseStudyUrl = (caseStudyUrl) => + UrlService.buildCaseStudyUrl(caseStudyUrl, req.query); + } + + /** + * Attaches show page data to request + * @param {Object} req - Request object + * @param {Object} navigation - Navigation data + */ + static attachShowData(req, navigation) { + const reqCopy = req; + if (!reqCopy.data) { + reqCopy.data = {}; + } + + const queryParams = req.query || {}; + + // Build complete URLs server-side to avoid template encoding issues + reqCopy.data.prev = navigation.prev; + reqCopy.data.next = navigation.next; + + // Generate URLs with query parameters server-side + if (navigation.prev) { + reqCopy.data.prevUrl = UrlService.buildCaseStudyUrl( + // eslint-disable-next-line no-underscore-dangle + navigation.prev._url, + queryParams, + ); + } + + if (navigation.next) { + reqCopy.data.nextUrl = UrlService.buildCaseStudyUrl( + // eslint-disable-next-line no-underscore-dangle + navigation.next._url, + queryParams, + ); + } + + reqCopy.data.backUrl = UrlService.buildCaseStudyUrl('/cases', queryParams); + reqCopy.data.query = queryParams; + } +} + +module.exports = UrlService; diff --git a/website/modules/case-studies-page/views/index.html b/website/modules/case-studies-page/views/index.html index 3568217d..07c54d02 100644 --- a/website/modules/case-studies-page/views/index.html +++ b/website/modules/case-studies-page/views/index.html @@ -204,7 +204,7 @@
{% for article in data.pieces %} {# CARD START: {{ article.title }} #} - + {% set picture = apos.image.first(article.picture) %} {% if picture is defined %} -
- {% set picture = apos.image.first(data.piece.picture) %} {% if picture is - defined %} +
+
+ + - {{ picture._alt or '' }} + +
+
+ {% if data.prev %} + + + previous project + + {% else %} + + + previous project + + {% endif %} +
- {% else %} +
+ {% if data.next %} + + next project + + + {% else %} + + next project + + + {% endif %} +
+
+
+ +
+
+ {% set picture = apos.image.first(data.piece.picture) %} {% if picture is + defined %} -
Placeholder banner -
- {% endif %} -
-
-
- {% if data.piece.clientWebsite %} -

+ {% else %} + +
+ Placeholder banner +
+ + {% endif %} + +
+
+
+ {% if data.piece.clientWebsite %} +

+ {{ data.piece.title }} +

+ {% else %} +

{{ data.piece.title }}

+ {% endif %} {% if data.piece._industry and + data.piece._industry.length %} +
+ {% for industry in data.piece._industry %} {{ industry.title | + e}}{% if not loop.last %}, {% endif %} {% endfor %} +
+ {% endif %} {% if data.piece.portfolioTitle %} +

{{ data.piece.portfolioTitle }}

+ {% endif %} {% if data.piece.descriptor %} +

{{ data.piece.descriptor }}

+ {% endif %} {% if data.piece._caseStudyType and + data.piece._caseStudyType.length %} +
+ {% for type in data.piece._caseStudyType %} {{ type.title | e }}{% + if not loop.last %}, {% endif %} {% endfor %} +
+ {% endif %} +
+ +

- {% else %} -

{{ data.piece.title }}

- {% endif %} {% if data.piece._industry and data.piece._industry.length - %} -
- {% for industry in data.piece._industry %} {{ industry.title | e}}{% - if not loop.last %}, {% endif %} {% endfor %} -
- {% endif %} {% if data.piece.portfolioTitle %} -

{{ data.piece.portfolioTitle }}

- {% endif %} {% if data.piece.descriptor %} -

{{ data.piece.descriptor }}

- {% endif %} {% if data.piece._caseStudyType and - data.piece._caseStudyType.length %} -
- {% for type in data.piece._caseStudyType %} {{ type.title | e }}{% - if not loop.last %}, {% endif %} {% endfor %} + {% endif %} {% if data.piece.prodLink %} + {{ data.piece.prodLink }} + {% endif %}
- {% endif %}
- - -
- {% if data.piece.objective %} -
-

Objective

-

{{ data.piece.objective }}

-
- {% endif %} {% if data.piece.challenge %} -
-

Challenge

-

{{ data.piece.challenge }}

-
- {% endif %} {% if data.piece.solution %} -
-

Solution

-

{{ data.piece.solution }}

-
- {% endif %} {% if data.piece.results %} -
-

Results

-

{{ data.piece.results }}

-
- {% endif %} {% if data.piece._stack and data.piece._stack.length %} -
-

Tech stack

-

- {% for tech in data.piece._stack %} {{ tech.title | e }}{% if not - loop.last %}, {% endif %} {% endfor %} -

-
- {% endif %} -
-
-
+
+
+
{% area data.piece, 'testimonials' %}
-
{% area data.piece, 'testimonials' %}
+ {% endblock main %} diff --git a/website/public/images/arrow-back-desktop.svg b/website/public/images/arrow-back-desktop.svg new file mode 100644 index 00000000..b022a042 --- /dev/null +++ b/website/public/images/arrow-back-desktop.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/website/public/images/arrow-left.svg b/website/public/images/arrow-left.svg new file mode 100644 index 00000000..c5e5533d --- /dev/null +++ b/website/public/images/arrow-left.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/website/public/images/arrow-right.svg b/website/public/images/arrow-right.svg new file mode 100644 index 00000000..e623ff55 --- /dev/null +++ b/website/public/images/arrow-right.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file