From 2e14e1a70580f96b79af7794de7cacd0c86ffdc3 Mon Sep 17 00:00:00 2001 From: Mikael Brevik Date: Fri, 29 Nov 2024 19:53:16 +0100 Subject: [PATCH 01/11] fix: cache employee and optimise fetching single person (#925) * fix: cache employee and optimise fetching single person * fix: optimise employees from list --- .../sections/employees/EmployeeList.tsx | 9 +-- .../sections/employees/Employees.tsx | 4 +- src/utils/employees.ts | 63 ++++++++++++------- src/utils/pageData.ts | 7 +-- 4 files changed, 50 insertions(+), 33 deletions(-) diff --git a/src/components/sections/employees/EmployeeList.tsx b/src/components/sections/employees/EmployeeList.tsx index bb041e4cc..4e8a73a76 100644 --- a/src/components/sections/employees/EmployeeList.tsx +++ b/src/components/sections/employees/EmployeeList.tsx @@ -36,9 +36,8 @@ export default function EmployeeList({ }: EmployeesProps) { const employeesRes = use(employeesPromise); const employees = employeesRes.ok ? employeesRes.value : []; - const [filteredEmployees, setFilteredEmployees] = useState< - ChewbaccaEmployee[] - >(shuffleEmployees(employees)); + const [filteredEmployees, setFilteredEmployees] = + useState(employees); const locations = Array.from(new Set(employees.map((e) => e.officeName))); const t = useTranslations("employee_card"); @@ -161,7 +160,3 @@ export default function EmployeeList({ ); } - -function shuffleEmployees(employees: ChewbaccaEmployee[]) { - return employees.sort(() => Math.random() - 0.5); -} diff --git a/src/components/sections/employees/Employees.tsx b/src/components/sections/employees/Employees.tsx index 1f0c51426..6bf7d83f7 100644 --- a/src/components/sections/employees/Employees.tsx +++ b/src/components/sections/employees/Employees.tsx @@ -26,7 +26,9 @@ export default async function Employees({ language, section }: EmployeesProps) { ); const employeesPageSlug = employeesPageRes.data.slug; - const countryTld = domainFromHostname(headers().get("host")).split(".")[1]; + const countryTld = domainFromHostname(headers().get("host")) + .split(".") + .at(-1); const employees = fetchAllChewbaccaEmployees(countryTld); return ( diff --git a/src/utils/employees.ts b/src/utils/employees.ts index 38bdf5e25..4a031cbf0 100644 --- a/src/utils/employees.ts +++ b/src/utils/employees.ts @@ -14,7 +14,13 @@ export async function fetchAllChewbaccaEmployees( const url = countryCode ? `${CHEWBACCA_URL}/employees?country=${countryCode}` : `${CHEWBACCA_URL}/employees`; - const employeesRes = await fetch(url); + + const employeesRes = await fetch(url, { + next: { + revalidate: 60 * 60 * 24, + }, + }); + if (!employeesRes.ok) { return ResultError( `Fetch returned status ${employeesRes.status} ${employeesRes.statusText}`, @@ -26,23 +32,29 @@ export async function fetchAllChewbaccaEmployees( `Expected ChewbaccaEmployeesResponse, was ${employeesData}`, ); } - return ResultOk(employeesData.employees); + return ResultOk(shuffleEmployees(employeesData.employees)); +} +function shuffleEmployees(employees: ChewbaccaEmployee[]) { + return employees.sort(() => Math.random() - 0.5); } export async function fetchChewbaccaEmployee( - email: string, + alias: string, + hostname: string | null, ): Promise> { - const allEmployeesRes = await fetchAllChewbaccaEmployees(); - if (!allEmployeesRes.ok) { - return allEmployeesRes; - } - const employee = allEmployeesRes.value.find( - (employee) => employee.email === email, - ); - if (!employee) { + const url = `${CHEWBACCA_URL}/employees/${aliasFromEmail(alias)}?country=${countryCodeFromEmail(hostname)}`; + + const employeeRes = await fetch(url, { + next: { + revalidate: 60 * 60 * 24, + }, + }); + + if (!employeeRes.ok) { return ResultError("Employee does not exist for given email"); } - return ResultOk(employee); + + return ResultOk(await employeeRes.json()); } export function emailFromAliasAndHostname( @@ -55,7 +67,9 @@ export function emailFromAliasAndHostname( export function aliasFromEmail(email: string): string { return email.split("@")[0]; } - +export function countryCodeFromEmail(hostname: string | null): string { + return domainFromHostname(hostname).split(".").at(-1) ?? "no"; +} export function domainFromEmail(email: string) { return email.split("@")[1]; } @@ -67,12 +81,19 @@ export async function fetchEmployeesByEmails( if (!allEmployeesRes.ok) { return allEmployeesRes; } - return ResultOk( - // mapping from input array (instead of filtering all employees) to preserve order - emails - .map((email) => - allEmployeesRes.value.find((employee) => employee.email === email), - ) - .filter((employee) => employee !== undefined), - ); + + const allResults = ( + await Promise.all( + emails.map((email) => { + const alias = aliasFromEmail(email); + const hostname = domainFromEmail(email); + return fetchChewbaccaEmployee(alias, hostname); + }), + ) + ).reduce((acc, employee) => { + if (!employee.ok) return acc; + return acc.concat(employee.value); + }, [] as ChewbaccaEmployee[]); + + return ResultOk(allResults); } diff --git a/src/utils/pageData.ts b/src/utils/pageData.ts index e24e8d8eb..37ad84121 100644 --- a/src/utils/pageData.ts +++ b/src/utils/pageData.ts @@ -35,7 +35,7 @@ import { CUSTOMER_CASE_QUERY } from "studioShared/lib/queries/customerCases"; import { loadSharedQuery } from "studioShared/lib/store"; import { customerCaseID } from "studioShared/schemas/documents/customerCase"; -import { emailFromAliasAndHostname, fetchChewbaccaEmployee } from "./employees"; +import { fetchChewbaccaEmployee } from "./employees"; import { isNonNullQueryResponse } from "./queryResponse"; import { domainFromHostname } from "./url"; @@ -282,9 +282,8 @@ async function fetchEmployeePage({ return null; } const employeeAlias = path[1]; - const employee = await fetchChewbaccaEmployee( - emailFromAliasAndHostname(employeeAlias, hostname), - ); + const employee = await fetchChewbaccaEmployee(employeeAlias, hostname); + if (!employee.ok) { return null; } From 513b262812d7a4d557e8db34f034bed65c1ebff9 Mon Sep 17 00:00:00 2001 From: Mikael Brevik Date: Mon, 2 Dec 2024 11:19:05 +0100 Subject: [PATCH 02/11] feat: adds contact box section (#928) * feat: adds section schema for contact box * adds contact box * makes titles translateable * feat: adds component and basic functionality for contact box * feat: adds option for light/dark mode for contact box * initial unfinished design * fix: ambiguous syntax * fix: set contact selector as proper tablist * feat: adds background dark/light mode to Tag * fix: sizing for employee card * fix: skeleton loading with container queries * fix: responsiveness contact box * refactor: renames design mode to background to align with sketches * fix: employee skeleton dark/light background * fix: simplify email type * fix: adds description to tag field in contact box --- src/components/employeeCard/EmployeeCard.tsx | 117 +++++++++++------- .../employeeCard/employeeCard.module.css | 73 +++++++---- .../sections/contact-box/ContactBox.tsx | 87 +++++++++++++ .../contact-box/ContactBoxPreview.tsx | 37 ++++++ .../sections/contact-box/ContactSelector.tsx | 73 +++++++++++ .../contact-box/contact-box.module.css | 76 ++++++++++++ src/components/tag/index.tsx | 20 ++- src/components/tag/tag.module.css | 41 ++++-- src/utils/renderSection.tsx | 3 + studio/lib/interfaces/pages.ts | 14 +++ studio/lib/queries/pages.ts | 4 + studio/schemas/documents/pageBuilder.ts | 2 + studio/schemas/fields/text.ts | 2 +- .../schemas/objects/sections/contact-box.ts | 111 +++++++++++++++++ 14 files changed, 571 insertions(+), 89 deletions(-) create mode 100644 src/components/sections/contact-box/ContactBox.tsx create mode 100644 src/components/sections/contact-box/ContactBoxPreview.tsx create mode 100644 src/components/sections/contact-box/ContactSelector.tsx create mode 100644 src/components/sections/contact-box/contact-box.module.css create mode 100644 studio/schemas/objects/sections/contact-box.ts diff --git a/src/components/employeeCard/EmployeeCard.tsx b/src/components/employeeCard/EmployeeCard.tsx index f4d74ac36..056c5b765 100644 --- a/src/components/employeeCard/EmployeeCard.tsx +++ b/src/components/employeeCard/EmployeeCard.tsx @@ -24,64 +24,85 @@ export default function EmployeeCard({ employee.name && employee.email && (
- -
- {employee.name} -
- -
- - - {employee.name} - - - -
- {employee.competences.map((competence) => ( - + +
+ {employee.name} +
+ +
+ + - {competence} - - ))} -
+ {employee.name} + +
- - {employee.email} - - {employee.telephone && ( - - - {formatPhoneNumber(employee.telephone)} - +
+ {employee.competences.map((competence) => ( + + {competence} + + ))} +
+ + + {employee.email} - )} + {employee.telephone && ( + + + {formatPhoneNumber(employee.telephone)} + + + )} +
) ); } -export function EmployeeCardSkeleton() { +export function EmployeeCardSkeleton({ + background = "light", +}: { + background?: "light" | "dark"; +}) { + const backgroundClass = + background === "dark" ? styles["employeeImage--dark"] : ""; + const backgroundClassText = + background === "dark" + ? `${styles.skeletonText} ${styles["skeletonText--dark"]}` + : styles.skeletonText; return ( -
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
); } diff --git a/src/components/employeeCard/employeeCard.module.css b/src/components/employeeCard/employeeCard.module.css index c99bd84b2..920269654 100644 --- a/src/components/employeeCard/employeeCard.module.css +++ b/src/components/employeeCard/employeeCard.module.css @@ -4,18 +4,6 @@ gap: 1rem; } -.employeeImage { - display: flex; - flex-direction: column; - align-items: center; - background-color: var(--background-bg-dark); - border-radius: 12px; - height: 125px; - width: 50%; - padding: 1rem; - position: relative; -} - .employeeInfoWrapper { display: flex; flex-direction: column; @@ -32,15 +20,23 @@ .employeeNameLink { text-decoration: none; + color: currentColor; +} + +.employeeEmail a, +.employeePhone a { + color: currentColor; } .employeeWrapper { + container-type: inline-size; + container-name: employee; +} +.employeeWrapper__inner { display: flex; flex-direction: column; - align-items: flex-start; - min-width: 280px; - max-width: var(--Text-paragraph, 537px); - gap: var(--small, 6px); + gap: 1rem; + row-gap: var(--small, 6px); } .employeeInfoWrapper { @@ -48,21 +44,23 @@ text-wrap: wrap; flex-direction: column; width: 100%; - height: fit-content; gap: 0.25rem; - align-self: stretch; } .employeeImage { display: block; height: 206px; - min-width: 280px; + min-width: 206px; + width: 100%; max-width: var(--Text-paragraph, 537px); position: relative; background-color: var(--background-bg-dark); border-radius: var(--medium, 12px); } +.employeeImage--dark { + background-color: var(--background-bg-light-primary); +} .employeeInfo { color: var(--stroke-tertiary, #5e5e5e); @@ -73,14 +71,10 @@ } .employeeName { - color: var(--text-primary, #222424); + color: currentColor; } .employeeRole { - display: flex; - flex-direction: row; - overflow: visible; - align-self: stretch; } .employeeRoleDot::after { @@ -101,17 +95,20 @@ color: var(--text-tertiary, #5e5e5e); } -/* Update the skeleton styles */ +/* Skeleton styles */ .skeletonCard { animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; } .skeletonText { height: 20px; - background-color: var(--background-bg-light-primary); + background-color: var(--background-bg-dark); border-radius: 4px; align-self: flex-start; } +.skeletonText--dark { + background-color: var(--background-bg-light-primary); +} .skeletonName { width: 150px; @@ -134,3 +131,27 @@ opacity: 0.5; } } + +@container employee (min-width: 380px) { + .employeeWrapper__inner { + flex-direction: row; + } + .employeeEmail { + margin-top: auto; + } + .employeeEmail, + .employeePhone, + .employeeRoleDot { + font-size: 1rem; + } + .employeePhone { + margin-top: 0.5rem; + } + .employeeInfoWrapper { + padding: 0.5rem 0; + } + + .skeletonAutoMargin { + margin-top: auto; + } +} diff --git a/src/components/sections/contact-box/ContactBox.tsx b/src/components/sections/contact-box/ContactBox.tsx new file mode 100644 index 000000000..57989e386 --- /dev/null +++ b/src/components/sections/contact-box/ContactBox.tsx @@ -0,0 +1,87 @@ +import { Suspense } from "react"; + +import { EmployeeCardSkeleton } from "src/components/employeeCard/EmployeeCard"; +import Text from "src/components/text/Text"; +import { ChewbaccaEmployee } from "src/types/employees"; +import { fetchEmployeesByEmails } from "src/utils/employees"; +import { ContactBoxSection } from "studio/lib/interfaces/pages"; +import { EMPLOYEE_PAGE_SLUG_QUERY } from "studio/lib/queries/siteSettings"; +import { loadStudioQuery } from "studio/lib/store"; + +import styles from "./contact-box.module.css"; +import ContactSelector, { EmployeeAndTag } from "./ContactSelector"; + +export interface ContactBoxProps { + section: ContactBoxSection; + language: string; +} + +export default async function ContactBox({ + section, + language, +}: ContactBoxProps) { + const employeesPageRes = await loadStudioQuery<{ slug: string }>( + EMPLOYEE_PAGE_SLUG_QUERY, + { + language, + }, + ); + const employeesPageSlug = employeesPageRes.data.slug; + + const contactPoints = fetchEmployeesByEmails( + section.contactPoints.map((contactPoint) => contactPoint.email), + ).then((result) => + result.ok + ? result.value.map((e) => employeeAndTag(e, section.contactPoints)) + : [], + ); + + const backgroundClass = + section.background === "light" ? styles["contactBox__inner--light"] : ""; + + return ( +
+
+
+ + {section.basicTitle} + + + {section.optionalSubtitle && ( + {section.optionalSubtitle} + )} +
+ +
+ } + > + + +
+
+
+ ); +} + +function employeeAndTag( + employee: ChewbaccaEmployee, + contactPoints: ContactBoxSection["contactPoints"], +): EmployeeAndTag { + const tag = + contactPoints.find((contactPoint) => contactPoint.email === employee.email) + ?.tag ?? ""; + return { + employee, + tag, + tagSlug: slugify(tag), + }; +} +function slugify(tag: string) { + return tag.toLowerCase().replace(/ /g, "-"); +} diff --git a/src/components/sections/contact-box/ContactBoxPreview.tsx b/src/components/sections/contact-box/ContactBoxPreview.tsx new file mode 100644 index 000000000..1c2eb9e62 --- /dev/null +++ b/src/components/sections/contact-box/ContactBoxPreview.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useQuery } from "@sanity/react-loader"; +import { Suspense } from "react"; + +import { PreviewProps } from "src/types/preview"; +import { ContactBoxSection, PageBuilder } from "studio/lib/interfaces/pages"; +import { PAGE_QUERY } from "studio/lib/queries/pages"; + +import ContactBox from "./ContactBox"; + +export default function ContactBoxPreview({ + initialData, + sectionIndex, +}: PreviewProps) { + const { data: newData } = useQuery( + PAGE_QUERY, + { id: initialData.data._id, language: initialData.data.language }, + { initial: initialData }, + ); + + const section = newData + ? (newData.sections.find( + (section, index) => + section._type === "contactBox" && index === sectionIndex, + ) as ContactBoxSection) + : (initialData.data.sections.find( + (section, index) => + section._type === "contactBox" && index === sectionIndex, + ) as ContactBoxSection); + + return ( + + + + ); +} diff --git a/src/components/sections/contact-box/ContactSelector.tsx b/src/components/sections/contact-box/ContactSelector.tsx new file mode 100644 index 000000000..e9936b78d --- /dev/null +++ b/src/components/sections/contact-box/ContactSelector.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { use, useState } from "react"; + +import EmployeeCard from "src/components/employeeCard/EmployeeCard"; +import { Tag } from "src/components/tag"; +import { ChewbaccaEmployee } from "src/types/employees"; + +import styles from "./contact-box.module.css"; + +export type EmployeeAndTag = { + employee: ChewbaccaEmployee; + tag: string; + tagSlug: string; +}; + +export type ContactSelectorProps = { + contactPoints: Promise; + employeesPageSlug: string; + language: string; + background?: "dark" | "light"; +}; + +export default function ContactSelector({ + contactPoints: contactPointsPromise, + employeesPageSlug, + language, + background = "dark", +}: ContactSelectorProps) { + const contactPoints = use(contactPointsPromise); + + const [selectedTag, setSelectedTag] = useState( + contactPoints[0].tagSlug, + ); + + return ( +
+
+ {contactPoints.map((contactPoint) => ( + setSelectedTag(contactPoint.tagSlug)} + text={contactPoint.tag} + /> + ))} +
+
+ {contactPoints.map((contactPoint) => ( + + ))} +
+
+ ); +} diff --git a/src/components/sections/contact-box/contact-box.module.css b/src/components/sections/contact-box/contact-box.module.css new file mode 100644 index 000000000..be33fa6e2 --- /dev/null +++ b/src/components/sections/contact-box/contact-box.module.css @@ -0,0 +1,76 @@ +.contactBox { + margin: 8rem auto; + padding: 0 1rem; + max-width: var(--max-content-width-large); +} + +.contactBox__inner { + --_contactBox__background: var(--background-bg-dark); + --_contactBox__color: var(--text-primary-light); + + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + + padding: 1.5rem 3rem; + flex-direction: column; + justify-content: flex-end; + align-items: flex-start; + gap: var(--Padding-l, 48px); + align-self: stretch; + + border-radius: var(--radius-small); + background: var(--_contactBox__background); + color: var(--_contactBox__color); +} +.contactBox__inner--light { + --_contactBox__background: var(--background-bg-light-primary); + --_contactBox__color: var(--text-primary); +} + +.textContent { + height: 100%; + display: flex; + flex-direction: column; + gap: 1rem; + + justify-content: flex-end; +} + +.contactSelectorWrapper { + container-type: inline-size; + container-name: contactSelectorWrapper; +} + +.contactSelector { + display: flex; + gap: 1rem; +} + +.tagList { + display: flex; + flex-direction: column; + gap: 0.75rem; + + align-items: flex-start; + min-width: 120px; +} + +.employeeCard { + flex: 1; +} + +@media (max-width: 400px) { + .contactBox__inner { + padding: 1rem 1rem; + } +} + +@container contactSelectorWrapper (max-width: 350px) { + .contactSelector { + flex-direction: column; + } + + .tagList { + flex-direction: row; + } +} diff --git a/src/components/tag/index.tsx b/src/components/tag/index.tsx index 18f16130e..468d3ae93 100644 --- a/src/components/tag/index.tsx +++ b/src/components/tag/index.tsx @@ -5,22 +5,30 @@ import Text from "src/components/text/Text"; import styles from "./tag.module.css"; type TagInner = - | { + | ({ type: "button"; onClick?: () => void; - } - | { + } & JSX.IntrinsicElements["button"]) + | ({ type: "link"; href: string; - }; + } & JSX.IntrinsicElements["link"]); type TagProps = { active?: boolean; + background?: "light" | "dark"; text: string; } & TagInner; -export const Tag = ({ text, active = false, ...props }: TagProps) => { - const className = `${styles.tag} ${active ? styles["tag--active"] : ""}`; +export const Tag = ({ + text, + background = "light", + active = false, + ...props +}: TagProps) => { + const activeClass = active ? styles["tag--active"] : ""; + const bgDarkClass = background === "dark" ? styles["tag--bgDark"] : ""; + const className = `${styles.tag} ${activeClass} ${bgDarkClass}`; if (props.type === "button") { return (