From dd5e67f647821d8c4ab63088a1ee96106aa7006e Mon Sep 17 00:00:00 2001 From: Mathias Oterhals Myklebust <24361490+mathiazom@users.noreply.github.com> Date: Wed, 30 Oct 2024 14:37:00 +0000 Subject: [PATCH] v3 - customer case split section (#841) * feat(Text): adjust font sizes * feat(CustomerCase): main image top padding * fix(ImageBlock): handle missing image alt for preview * feat(CustomerCase): split section * feat(SplitSection): column flex on narrow screens * feat(SplitSection): hide empty section on narrow screens --- .../customerCase/CustomerCase.tsx | 36 +------------- .../customerCase/customerCase.module.css | 28 +---------- .../sections/CustomerCaseSection.tsx | 23 +++++++++ .../sections/quote/QuoteBlock.tsx | 29 +++++++++++ .../sections/quote/quoteBlock.module.css | 26 ++++++++++ .../sections/splitSection/SplitSection.tsx | 35 ++++++++++++++ .../splitSection/splitSection.module.css | 28 +++++++++++ src/components/text/text.module.css | 36 +------------- studio/lib/interfaces/global.ts | 14 ++++++ studio/lib/interfaces/richText.ts | 16 +------ studio/schemas/fields/media.ts | 5 +- studio/utils/stringUtils.ts | 19 ++++++++ studioShared/components/EmptySectionInput.tsx | 14 ++++++ studioShared/lib/interfaces/customerCases.ts | 5 +- studioShared/lib/interfaces/splitSection.ts | 30 ++++++++++++ studioShared/lib/queries/customerCases.ts | 48 ++++++++++++------- .../schemas/documents/customerCase.ts | 10 ++-- studioShared/schemas/objects/emptySection.ts | 29 +++++++++++ studioShared/schemas/objects/imageBlock.ts | 12 +++-- studioShared/schemas/objects/sections.ts | 11 +++++ studioShared/schemas/objects/splitSection.ts | 44 +++++++++++++++++ 21 files changed, 356 insertions(+), 142 deletions(-) create mode 100644 src/components/customerCases/customerCase/sections/CustomerCaseSection.tsx create mode 100644 src/components/customerCases/customerCase/sections/quote/QuoteBlock.tsx create mode 100644 src/components/customerCases/customerCase/sections/quote/quoteBlock.module.css create mode 100644 src/components/customerCases/customerCase/sections/splitSection/SplitSection.tsx create mode 100644 src/components/customerCases/customerCase/sections/splitSection/splitSection.module.css create mode 100644 studioShared/components/EmptySectionInput.tsx create mode 100644 studioShared/lib/interfaces/splitSection.ts create mode 100644 studioShared/schemas/objects/emptySection.ts create mode 100644 studioShared/schemas/objects/sections.ts create mode 100644 studioShared/schemas/objects/splitSection.ts diff --git a/src/components/customerCases/customerCase/CustomerCase.tsx b/src/components/customerCases/customerCase/CustomerCase.tsx index 9b61adce6..a1ae4e072 100644 --- a/src/components/customerCases/customerCase/CustomerCase.tsx +++ b/src/components/customerCases/customerCase/CustomerCase.tsx @@ -3,47 +3,13 @@ import Text from "src/components/text/Text"; import { fetchEmployeesByEmails } from "src/utils/employees"; import { CustomerCase as CustomerCaseDocument, - CustomerCaseSection as CustomerCaseSectionObject, Delivery, } from "studioShared/lib/interfaces/customerCases"; import styles from "./customerCase.module.css"; import FeaturedCases from "./featuredCases/FeaturedCases"; import CustomerCaseConsultants from "./sections/customerCaseConsultants/CustomerCaseConsultants"; -import ImageSection from "./sections/image/ImageSection"; -import RichTextSection from "./sections/richText/RichTextSection"; - -function CustomerCaseSection({ - section, -}: { - section: CustomerCaseSectionObject; -}) { - switch (section._type) { - case "richTextBlock": - return ; - case "quoteBlock": - return ( - section.quote && ( -
-
- - {"“"} - {section.quote} - {"”"} - - {section.author && ( - - {section.author} - )} -
-
- ) - ); - case "imageBlock": - return ; - } -} +import { CustomerCaseSection } from "./sections/CustomerCaseSection"; export interface CustomerCaseProps { customerCase: CustomerCaseDocument; diff --git a/src/components/customerCases/customerCase/customerCase.module.css b/src/components/customerCases/customerCase/customerCase.module.css index 8f8901922..647ecc9f5 100644 --- a/src/components/customerCases/customerCase/customerCase.module.css +++ b/src/components/customerCases/customerCase/customerCase.module.css @@ -31,6 +31,7 @@ width: 100%; height: 36.5rem; overflow: hidden; + padding-top: 1.5rem; padding-bottom: 2rem; } @@ -109,30 +110,3 @@ flex-direction: column; gap: 4.5rem; } - -.quoteBlock { - align-self: center; - max-width: 960px; - width: 100%; - border: 2px solid var(--primary-yellow-warning); - border-radius: 0.5rem; - background-color: var(--primary-yellow-warning); -} - -.quoteBlockInner { - display: flex; - flex-direction: column; - background-color: var(--primary-bg); - border-radius: 2rem; - gap: 1.25rem; - width: 100%; - align-items: center; -} - -.withAuthor { - padding: 4.5rem 0.5rem 3.5rem 0.5rem; -} - -.withoutAuthor { - padding: 4.5rem 0.5rem; -} diff --git a/src/components/customerCases/customerCase/sections/CustomerCaseSection.tsx b/src/components/customerCases/customerCase/sections/CustomerCaseSection.tsx new file mode 100644 index 000000000..052c7a06f --- /dev/null +++ b/src/components/customerCases/customerCase/sections/CustomerCaseSection.tsx @@ -0,0 +1,23 @@ +import { CustomerCaseSection as CustomerCaseSectionObject } from "studioShared/lib/interfaces/customerCases"; + +import ImageSection from "./image/ImageSection"; +import QuoteBlock from "./quote/QuoteBlock"; +import RichTextSection from "./richText/RichTextSection"; +import SplitSection from "./splitSection/SplitSection"; + +export function CustomerCaseSection({ + section, +}: { + section: CustomerCaseSectionObject; +}) { + switch (section._type) { + case "splitSection": + return ; + case "richTextBlock": + return ; + case "quoteBlock": + return ; + case "imageBlock": + return ; + } +} diff --git a/src/components/customerCases/customerCase/sections/quote/QuoteBlock.tsx b/src/components/customerCases/customerCase/sections/quote/QuoteBlock.tsx new file mode 100644 index 000000000..ce16e0ae6 --- /dev/null +++ b/src/components/customerCases/customerCase/sections/quote/QuoteBlock.tsx @@ -0,0 +1,29 @@ +import Text from "src/components/text/Text"; +import { QuoteBlock as QuoteBlockObject } from "studioShared/lib/interfaces/quoteBlock"; + +import styles from "./quoteBlock.module.css"; + +export interface QuoteBlockProps { + section: QuoteBlockObject; +} + +export default function QuoteBlock({ section }: QuoteBlockProps) { + return ( + section.quote && ( +
+
+ + {"“"} + {section.quote} + {"”"} + + {section.author && ( + - {section.author} + )} +
+
+ ) + ); +} diff --git a/src/components/customerCases/customerCase/sections/quote/quoteBlock.module.css b/src/components/customerCases/customerCase/sections/quote/quoteBlock.module.css new file mode 100644 index 000000000..5461c16b8 --- /dev/null +++ b/src/components/customerCases/customerCase/sections/quote/quoteBlock.module.css @@ -0,0 +1,26 @@ +.quoteBlock { + align-self: center; + max-width: 960px; + width: 100%; + border: 2px solid var(--primary-yellow-warning); + border-radius: 0.5rem; + background-color: var(--primary-yellow-warning); +} + +.quoteBlockInner { + display: flex; + flex-direction: column; + background-color: var(--primary-bg); + border-radius: 2rem; + gap: 1.25rem; + width: 100%; + align-items: center; +} + +.withAuthor { + padding: 4.5rem 0.5rem 3.5rem 0.5rem; +} + +.withoutAuthor { + padding: 4.5rem 0.5rem; +} diff --git a/src/components/customerCases/customerCase/sections/splitSection/SplitSection.tsx b/src/components/customerCases/customerCase/sections/splitSection/SplitSection.tsx new file mode 100644 index 000000000..12ac8ccb3 --- /dev/null +++ b/src/components/customerCases/customerCase/sections/splitSection/SplitSection.tsx @@ -0,0 +1,35 @@ +import { CustomerCaseSection } from "src/components/customerCases/customerCase/sections/CustomerCaseSection"; +import { + SplitSection as SplitSectionObject, + SplitSectionSection as SplitSectionSectionObject, +} from "studioShared/lib/interfaces/splitSection"; + +import styles from "./splitSection.module.css"; + +export interface SplitSectionProps { + section: SplitSectionObject; +} + +function SplitSectionSection({ + section, +}: { + section: SplitSectionSectionObject; +}) { + switch (section._type) { + case "emptySection": + return
; + } + return ; +} + +export default function SplitSection({ section }: SplitSectionProps) { + return ( +
+
+ {section.sections.map((section) => ( + + ))} +
+
+ ); +} diff --git a/src/components/customerCases/customerCase/sections/splitSection/splitSection.module.css b/src/components/customerCases/customerCase/sections/splitSection/splitSection.module.css new file mode 100644 index 000000000..68b56e4a4 --- /dev/null +++ b/src/components/customerCases/customerCase/sections/splitSection/splitSection.module.css @@ -0,0 +1,28 @@ +.wrapper { + display: flex; + flex-direction: column; + align-items: center; +} + +.content { + width: 100%; + max-width: 960px; + display: flex; + justify-content: space-evenly; + gap: 2rem; + + @media (max-width: 1024px) { + flex-direction: column; + } +} + +.content > * { + flex-basis: 0; + flex-grow: 1; +} + +.emptySection { + @media (max-width: 1024px) { + display: none; + } +} diff --git a/src/components/text/text.module.css b/src/components/text/text.module.css index 0f0db9350..71789029a 100644 --- a/src/components/text/text.module.css +++ b/src/components/text/text.module.css @@ -34,30 +34,17 @@ font-style: normal; font-weight: 600; line-height: 120%; - - @media (min-width: 1024px) { - font-size: 4.25rem; - } } .h2 { font-size: 2.5625rem; font-weight: 500; line-height: 110%; - - @media (min-width: 1024px) { - font-size: 3.375; - line-height: 120%; - } } .h3 { font-size: 2.125rem; font-weight: 600; - - @media (min-width: 1024px) { - font-size: 2.25rem; - } } /* TODO: add font variables */ @@ -69,20 +56,11 @@ font-size: 10rem; line-height: 100%; font-weight: 500; - - @media (min-width: 1024px) { - font-size: 5.25rem; - line-height: normal; - } } .bodyXl { font-size: 2.125rem; font-weight: 500; - - @media (min-width: 1024px) { - font-size: 2.625rem; - } } .bodyBig { @@ -90,28 +68,16 @@ font-style: normal; font-weight: 400; line-height: 130%; - - @media (min-width: 1024px) { - font-size: 1.875rem; - } } .bodyNormal { - font-size: 1.25rem; + font-size: 20px; font-weight: 400; - - @media (min-width: 1024px) { - font-size: 1.5rem; - } } .bodySmall { font-size: 1rem; font-weight: 400; - - @media (min-width: 1024px) { - font-size: 1.25rem; - } } /* TODO: add font variables */ diff --git a/studio/lib/interfaces/global.ts b/studio/lib/interfaces/global.ts index dbd856aaa..8b92afc0b 100644 --- a/studio/lib/interfaces/global.ts +++ b/studio/lib/interfaces/global.ts @@ -49,3 +49,17 @@ export interface InternationalizedValueRecord { } export type InternationalizedString = InternationalizedValueRecord[]; + +export function isSanityKeyTypeObject(value: unknown): value is { + _key: string; + _type: string; +} { + return ( + typeof value === "object" && + value !== null && + "_key" in value && + typeof value._key === "string" && + "_type" in value && + typeof value._type === "string" + ); +} diff --git a/studio/lib/interfaces/richText.ts b/studio/lib/interfaces/richText.ts index 29ed1ad8f..139225f4b 100644 --- a/studio/lib/interfaces/richText.ts +++ b/studio/lib/interfaces/richText.ts @@ -4,6 +4,8 @@ import { isPortableTextTextBlock, } from "sanity"; +import { isSanityKeyTypeObject } from "./global"; + export function isRichText(value: unknown): value is PortableTextBlock[] { return ( Array.isArray(value) && value.every((item) => isPortableTextBlock(item)) @@ -21,17 +23,3 @@ export function isPortableTextObject( ): value is PortableTextObject { return isSanityKeyTypeObject(value); } - -function isSanityKeyTypeObject(value: unknown): value is { - _key: string; - _type: string; -} { - return ( - typeof value === "object" && - value !== null && - "_key" in value && - typeof value._key === "string" && - "_type" in value && - typeof value._type === "string" - ); -} diff --git a/studio/schemas/fields/media.ts b/studio/schemas/fields/media.ts index 7a6450c9e..cf2078cc7 100644 --- a/studio/schemas/fields/media.ts +++ b/studio/schemas/fields/media.ts @@ -60,13 +60,14 @@ export const internationalizedImage = defineField({ media: "asset", }, prepare({ alt, media }) { - if (!isInternationalizedString(alt)) { + if (alt !== undefined && !isInternationalizedString(alt)) { throw new TypeError( `Expected 'alt' to be InternationalizedString, was ${typeof alt}`, ); } return { - title: firstTranslation(alt) ?? undefined, + title: + alt !== undefined ? (firstTranslation(alt) ?? undefined) : undefined, media, }; }, diff --git a/studio/utils/stringUtils.ts b/studio/utils/stringUtils.ts index dd10ca86d..972ed5cde 100644 --- a/studio/utils/stringUtils.ts +++ b/studio/utils/stringUtils.ts @@ -28,4 +28,23 @@ export function normalizeSpaces(string: string): string { return string.trim().replace(/\s+/g, " "); } +/** + * Converts a camel cased string (first letter can be either upper- or lowercase), + * to a more human-readable string where words are seperated with spaces and + * the first letter of each word is in uppercase + * + * ThisIsTheStringToSplit -> This Is The String To Split + * thisIsTheStringToSplit -> This Is The String To Split + * thisIsATrickyOne -> This Is A Tricky One + * + * @param {string} s + * @returns {string} + */ +export function humanizeCamelCase(s: string): string { + if (s.length === 0) { + return s; + } + return (s[0].toUpperCase() + s.slice(1)).split(/(?=[A-Z])/).join(" "); +} + // Add more string utility functions as needed... diff --git a/studioShared/components/EmptySectionInput.tsx b/studioShared/components/EmptySectionInput.tsx new file mode 100644 index 000000000..1ca06dfaa --- /dev/null +++ b/studioShared/components/EmptySectionInput.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { useEffect } from "react"; +import { StringInputProps, set } from "sanity"; + +export default function EmptySectionInput(props: StringInputProps) { + useEffect(() => { + // set hidden placeholder field to some arbitrary value + // to ensure the section is created + props.onChange(set("placeholder")); + }, [props]); + + return <>; +} diff --git a/studioShared/lib/interfaces/customerCases.ts b/studioShared/lib/interfaces/customerCases.ts index 48d14d16c..5fadaaf74 100644 --- a/studioShared/lib/interfaces/customerCases.ts +++ b/studioShared/lib/interfaces/customerCases.ts @@ -3,6 +3,7 @@ import { IImage } from "studio/lib/interfaces/media"; import { ImageBlock } from "./imageBlock"; import { QuoteBlock } from "./quoteBlock"; import { RichTextBlock } from "./richTextBlock"; +import { SplitSection } from "./splitSection"; export interface CustomerCaseProjectInfo { customer: string; @@ -27,7 +28,9 @@ export interface CustomerCaseBase { image: IImage; } -export type CustomerCaseSection = RichTextBlock | ImageBlock | QuoteBlock; +export type BaseCustomerCaseSection = RichTextBlock | ImageBlock | QuoteBlock; + +export type CustomerCaseSection = BaseCustomerCaseSection | SplitSection; export interface CustomerCase extends CustomerCaseBase { projectInfo: CustomerCaseProjectInfo; diff --git a/studioShared/lib/interfaces/splitSection.ts b/studioShared/lib/interfaces/splitSection.ts new file mode 100644 index 000000000..82bd0e301 --- /dev/null +++ b/studioShared/lib/interfaces/splitSection.ts @@ -0,0 +1,30 @@ +import { isSanityKeyTypeObject } from "studio/lib/interfaces/global"; +import { splitSectionSections } from "studioShared/schemas/objects/splitSection"; + +import { BaseCustomerCaseSection } from "./customerCases"; + +export interface EmptySection { + _key: string; + _type: "emptySection"; +} + +export type SplitSectionSection = BaseCustomerCaseSection | EmptySection; + +export interface SplitSection { + _key: string; + _type: "splitSection"; + sections: SplitSectionSection[]; +} + +export function isSplitSectionSections( + value: unknown, +): value is SplitSectionSection[] { + return ( + Array.isArray(value) && + value.every( + (item) => + isSanityKeyTypeObject(item) && + splitSectionSections.some((s) => s.name === item._type), + ) + ); +} diff --git a/studioShared/lib/queries/customerCases.ts b/studioShared/lib/queries/customerCases.ts index 49f68ed7a..2f8640b37 100644 --- a/studioShared/lib/queries/customerCases.ts +++ b/studioShared/lib/queries/customerCases.ts @@ -28,6 +28,28 @@ export const CUSTOMER_CASES_QUERY = groq` } `; +export const BASE_SECTIONS_FRAGMENT = groq` + _type == "richTextBlock" => { + "richText": ${translatedFieldFragment("richText")}, + }, + _type == "imageBlock" => { + "images": images[] { + ${INTERNATIONALIZED_IMAGE_FRAGMENT} + }, + fullWidth + }, + _type == "listBlock" => { + "description": ${translatedFieldFragment("description")}, + "list": list[] { + "text": ${translatedFieldFragment("text")}, + }, + }, + _type == "quoteBlock" => { + "quote": ${translatedFieldFragment("quote")}, + "author": ${translatedFieldFragment("author")}, + }, +`; + export const CUSTOMER_CASE_QUERY = groq` *[_type == "customerCase" && ${translatedFieldFragment("slug")} == $slug][0] { ${CUSTOMER_CASE_BASE_FRAGMENT}, @@ -44,25 +66,15 @@ export const CUSTOMER_CASE_QUERY = groq` "sections": sections[] { _key, _type, - _type == "richTextBlock" => { - "richText": ${translatedFieldFragment("richText")}, - }, - _type == "imageBlock" => { - "images": images[] { - ${INTERNATIONALIZED_IMAGE_FRAGMENT} - }, - fullWidth - }, - _type == "listBlock" => { - "description": ${translatedFieldFragment("description")}, - "list": list[] { - "text": ${translatedFieldFragment("text")}, - }, - }, - _type == "quoteBlock" => { - "quote": ${translatedFieldFragment("quote")}, - "author": ${translatedFieldFragment("author")}, + _type == "splitSection" => { + "sections": sections[] { + _key, + _type, + _type == "emptySection" => {}, + ${BASE_SECTIONS_FRAGMENT} + } }, + ${BASE_SECTIONS_FRAGMENT} }, "featuredCases": featuredCases[] -> { ${CUSTOMER_CASE_BASE_FRAGMENT} diff --git a/studioShared/schemas/documents/customerCase.ts b/studioShared/schemas/documents/customerCase.ts index d9a602a8d..180f295f8 100644 --- a/studioShared/schemas/documents/customerCase.ts +++ b/studioShared/schemas/documents/customerCase.ts @@ -8,13 +8,13 @@ import { titleSlug } from "studio/schemas/schemaTypes/slug"; import { buildDraftId, buildPublishedId } from "studio/utils/documentUtils"; import { firstTranslation } from "studio/utils/i18n"; import { customerCaseProjectInfo } from "studioShared/schemas/fields/customerCaseProjectInfo"; -import imageBlock from "studioShared/schemas/objects/imageBlock"; -import listBlock from "studioShared/schemas/objects/listBlock"; -import quoteBlock from "studioShared/schemas/objects/quoteBlock"; -import richTextBlock from "studioShared/schemas/objects/richTextBlock"; +import { baseCustomerCaseSections } from "studioShared/schemas/objects/sections"; +import splitSection from "studioShared/schemas/objects/splitSection"; export const customerCaseID = "customerCase"; +export const customerCaseSections = [...baseCustomerCaseSections, splitSection]; + const customerCase = defineType({ name: customerCaseID, type: "document", @@ -73,7 +73,7 @@ const customerCase = defineType({ title: "Sections", description: "Add sections here", type: "array", - of: [richTextBlock, imageBlock, listBlock, quoteBlock], + of: customerCaseSections, }), defineField({ name: "featuredCases", diff --git a/studioShared/schemas/objects/emptySection.ts b/studioShared/schemas/objects/emptySection.ts new file mode 100644 index 000000000..ebd00bded --- /dev/null +++ b/studioShared/schemas/objects/emptySection.ts @@ -0,0 +1,29 @@ +import { defineField } from "sanity"; + +import EmptySectionInput from "studioShared/components/EmptySectionInput"; + +const emptySection = defineField({ + name: "emptySection", + title: "Blank Space", + description: "Displays as blank space on the page.", + type: "object", + fields: [ + { + name: "placeholder", + title: "Just some blank space, nothing to see here", + type: "string", + components: { + input: EmptySectionInput, + }, + }, + ], + preview: { + prepare() { + return { + title: "Blank Space", + }; + }, + }, +}); + +export default emptySection; diff --git a/studioShared/schemas/objects/imageBlock.ts b/studioShared/schemas/objects/imageBlock.ts index a5368e810..715b28058 100644 --- a/studioShared/schemas/objects/imageBlock.ts +++ b/studioShared/schemas/objects/imageBlock.ts @@ -33,12 +33,14 @@ const imageBlock = defineField({ let firstImageAlt = null; if (firstImage !== undefined) { const imageAlt = firstImage.alt; - if (!isInternationalizedString(imageAlt)) { - throw new TypeError( - `Expected image 'alt' to be InternationalizedString, was ${typeof firstImage.alt}`, - ); + if (imageAlt !== undefined) { + if (!isInternationalizedString(imageAlt)) { + throw new TypeError( + `Expected image 'alt' to be InternationalizedString, was ${typeof firstImage.alt}`, + ); + } + firstImageAlt = firstTranslation(imageAlt); } - firstImageAlt = firstTranslation(imageAlt); } return { title: count > 1 ? `${count} images` : (firstImageAlt ?? undefined), diff --git a/studioShared/schemas/objects/sections.ts b/studioShared/schemas/objects/sections.ts new file mode 100644 index 000000000..3158f7214 --- /dev/null +++ b/studioShared/schemas/objects/sections.ts @@ -0,0 +1,11 @@ +import imageBlock from "./imageBlock"; +import listBlock from "./listBlock"; +import quoteBlock from "./quoteBlock"; +import richTextBlock from "./richTextBlock"; + +export const baseCustomerCaseSections = [ + richTextBlock, + imageBlock, + listBlock, + quoteBlock, +]; diff --git a/studioShared/schemas/objects/splitSection.ts b/studioShared/schemas/objects/splitSection.ts new file mode 100644 index 000000000..4d3bdad08 --- /dev/null +++ b/studioShared/schemas/objects/splitSection.ts @@ -0,0 +1,44 @@ +import { defineField } from "sanity"; + +import { humanizeCamelCase } from "studio/utils/stringUtils"; +import { isSplitSectionSections } from "studioShared/lib/interfaces/splitSection"; + +import emptySection from "./emptySection"; +import { baseCustomerCaseSections } from "./sections"; + +export const splitSectionSections = [...baseCustomerCaseSections, emptySection]; + +const splitSection = defineField({ + name: "splitSection", + title: "Split Section", + description: "Section containing two other sections, displayed side-by-side", + type: "object", + fields: [ + { + name: "sections", + title: "Sections", + type: "array", + of: splitSectionSections, + validation: (rule) => rule.length(2), + }, + ], + preview: { + select: { + sections: "sections", + }, + prepare({ sections }) { + if (!isSplitSectionSections(sections)) { + throw new TypeError( + "Expected 'sections' to be an array with element type SplitSectionSection.", + ); + } + return { + title: sections + .map((section) => humanizeCamelCase(section._type)) + .join(" | "), + }; + }, + }, +}); + +export default splitSection;