From e21f345180f7ff941613043a23063947fe2d56b9 Mon Sep 17 00:00:00 2001 From: KaiUweCZE Date: Tue, 4 Aug 2026 13:50:11 +0200 Subject: [PATCH 1/4] feat(herbatika): render homepage promo from CMS page --- apps/herbatika/src/app/[slug]/page.tsx | 20 +++++- apps/herbatika/src/app/page.tsx | 21 ++++-- .../src/components/herbatika-homepage.tsx | 9 ++- .../homepage/homepage.data.types.ts | 7 ++ .../sections/homepage-promo-section.tsx | 71 +++++++++++++------ .../src/lib/storefront/cms-homepage-promo.ts | 37 ++++++++++ .../herbatika/src/lib/storefront/cms-types.ts | 1 + apps/herbatika/src/lib/storefront/cms.ts | 6 ++ 8 files changed, 141 insertions(+), 31 deletions(-) create mode 100644 apps/herbatika/src/lib/storefront/cms-homepage-promo.ts diff --git a/apps/herbatika/src/app/[slug]/page.tsx b/apps/herbatika/src/app/[slug]/page.tsx index c4f2e7c7b8..7180e772d1 100644 --- a/apps/herbatika/src/app/[slug]/page.tsx +++ b/apps/herbatika/src/app/[slug]/page.tsx @@ -1,7 +1,10 @@ import type { Metadata } from "next" import { notFound } from "next/navigation" import { CmsPageSurface } from "@/components/cms/cms-page-surface" -import { fetchCmsPageBySlug } from "@/lib/storefront/cms" +import { + fetchCmsPageBySlug, + HOMEPAGE_PROMO_PAGE_SLUG, +} from "@/lib/storefront/cms" type CmsPageRouteProps = { params: Promise<{ @@ -13,6 +16,16 @@ export async function generateMetadata({ params, }: CmsPageRouteProps): Promise { const { slug } = await params + + if (slug === HOMEPAGE_PROMO_PAGE_SLUG) { + return { + robots: { + follow: false, + index: false, + }, + } + } + const page = await fetchCmsPageBySlug(slug) if (!page) { @@ -27,6 +40,11 @@ export async function generateMetadata({ export default async function CmsPageRoute({ params }: CmsPageRouteProps) { const { slug } = await params + + if (slug === HOMEPAGE_PROMO_PAGE_SLUG) { + notFound() + } + const page = await fetchCmsPageBySlug(slug) if (!page) { diff --git a/apps/herbatika/src/app/page.tsx b/apps/herbatika/src/app/page.tsx index c8e0bea7eb..c3d9687beb 100644 --- a/apps/herbatika/src/app/page.tsx +++ b/apps/herbatika/src/app/page.tsx @@ -1,17 +1,26 @@ import { HydrationBoundary } from "@tanstack/react-query" import { HerbatikaHomepage } from "@/components/herbatika-homepage" -import { fetchCmsHeroBanners } from "@/lib/storefront/cms" +import { + fetchCmsHeroBanners, + fetchCmsHomepagePromo, +} from "@/lib/storefront/cms" import { prefetchHomePageStorefrontData } from "@/lib/storefront/ssr" export default async function HomePage() { - const [{ dehydratedState }, heroBanners] = await Promise.all([ - prefetchHomePageStorefrontData(), - fetchCmsHeroBanners(), - ]) + const [{ dehydratedState }, heroBanners, homepagePromo] = await Promise.all( + [ + prefetchHomePageStorefrontData(), + fetchCmsHeroBanners(), + fetchCmsHomepagePromo(), + ] + ) return ( - + ) } diff --git a/apps/herbatika/src/components/herbatika-homepage.tsx b/apps/herbatika/src/components/herbatika-homepage.tsx index 279443fb90..3e837b970c 100644 --- a/apps/herbatika/src/components/herbatika-homepage.tsx +++ b/apps/herbatika/src/components/herbatika-homepage.tsx @@ -2,6 +2,7 @@ import { BENEFITS } from "@/assets/benefits" import type { HeroBannerItem } from "@/components/homepage/homepage.data" import { BLOG_POSTS, HERO_BANNERS } from "@/components/homepage/homepage.data" +import type { HomepagePromoContent } from "@/components/homepage/homepage.data.types" import { HomepageBlogSection } from "@/components/homepage/sections/homepage-blog-section" import { HomepageHeroCarouselSection } from "@/components/homepage/sections/homepage-hero-carousel-section" import { HomepageProductCollectionSection } from "@/components/homepage/sections/homepage-product-collection-section" @@ -14,9 +15,13 @@ import { PurposeCarousel } from "./homepage/sections/purpose-carousel" type HerbatikaHomepageProps = { heroBanners?: HeroBannerItem[] + homepagePromo?: HomepagePromoContent | null } -export function HerbatikaHomepage({ heroBanners }: HerbatikaHomepageProps) { +export function HerbatikaHomepage({ + heroBanners, + homepagePromo, +}: HerbatikaHomepageProps) { const controller = useHomepageController() const banners = heroBanners?.length ? heroBanners : HERO_BANNERS @@ -49,7 +54,7 @@ export function HerbatikaHomepage({ heroBanners }: HerbatikaHomepageProps) { ))} - + ) diff --git a/apps/herbatika/src/components/homepage/homepage.data.types.ts b/apps/herbatika/src/components/homepage/homepage.data.types.ts index 78ac8dfe62..b8abd0da72 100644 --- a/apps/herbatika/src/components/homepage/homepage.data.types.ts +++ b/apps/herbatika/src/components/homepage/homepage.data.types.ts @@ -36,3 +36,10 @@ export type ProductSectionDefinition = { titleKey: HomepageProductSectionTitleKey viewAllHref: string } + +export type HomepagePromoContent = { + contentHtml: string + heading: string + imageAlt?: string + imageSrc?: string +} diff --git a/apps/herbatika/src/components/homepage/sections/homepage-promo-section.tsx b/apps/herbatika/src/components/homepage/sections/homepage-promo-section.tsx index 3ee85a879a..cd83b760e2 100644 --- a/apps/herbatika/src/components/homepage/sections/homepage-promo-section.tsx +++ b/apps/herbatika/src/components/homepage/sections/homepage-promo-section.tsx @@ -1,41 +1,68 @@ import NextImage from "next/image" +import type { HomepagePromoContent } from "@/components/homepage/homepage.data.types" +import { sanitizeHtml } from "@/components/product-detail/utils/html-sanitizer" + +const DEFAULT_IMAGE = { + alt: "Predajňa Herbatika", + src: "https://images.unsplash.com/photo-1600093463592-8e36ae95ef56?auto=format&fit=crop&w=1100&q=80", +} + +type HomepagePromoSectionProps = { + promo?: HomepagePromoContent | null +} + +export function HomepagePromoSection({ promo }: HomepagePromoSectionProps) { + const imageAlt = promo?.imageAlt || DEFAULT_IMAGE.alt + const imageSrc = promo?.imageSrc || DEFAULT_IMAGE.src + const contentHtml = sanitizeHtml(promo?.contentHtml ?? "") -export function HomepagePromoSection() { return (

- Prírodná kozmetika, doplnky výživy a tradičná medicína + {promo?.heading ?? + "Prírodná kozmetika, doplnky výživy a tradičná medicína"}

-

- Spoznajte blahodarné účinky prírodnej kozmetiky a jej pozitívny vplyv - nielen na pokožku. Upevnite si vaše zdravie pomocou doplnkov stravy a - tradičnej medicíny.Toto všetko nájdete v našej pestrej ponuke, ktorá - je navyše obohatená aj o zdravotné doplnky z prírodných materiálov. -

-

- Špecializujeme sa na výber tých najkvalitnejších produktov, ktoré aj - my sami používame, vylepšujeme a opakovane testujeme. Máme radi - kvalitu a potrpíme si na detaily. Sme pripravení, pomôcť vám s výberom - produktov špeciálne podľa vašich potrieb alebo na váš zdravotný - problém. -

-

- Herbatica má už aj svoju značku, pod ktorou vyrábame množstvo - produktov, ktoré inde nenájdete. Máme radi kvalitu a detaily a na tie - sa sústredíme v každom našom produkte. -

+ {contentHtml ? ( +
+ ) : ( + <> +

+ Spoznajte blahodarné účinky prírodnej kozmetiky a jej pozitívny + vplyv nielen na pokožku. Upevnite si vaše zdravie pomocou doplnkov + stravy a tradičnej medicíny.Toto všetko nájdete v našej pestrej + ponuke, ktorá je navyše obohatená aj o zdravotné doplnky z + prírodných materiálov. +

+

+ Špecializujeme sa na výber tých najkvalitnejších produktov, ktoré + aj my sami používame, vylepšujeme a opakovane testujeme. Máme radi + kvalitu a potrpíme si na detaily. Sme pripravení, pomôcť vám s + výberom produktov špeciálne podľa vašich potrieb alebo na váš + zdravotný problém. +

+

+ Herbatica má už aj svoju značku, pod ktorou vyrábame množstvo + produktov, ktoré inde nenájdete. Máme radi kvalitu a detaily a na + tie sa sústredíme v každom našom produkte. +

+ + )}
) diff --git a/apps/herbatika/src/lib/storefront/cms-homepage-promo.ts b/apps/herbatika/src/lib/storefront/cms-homepage-promo.ts new file mode 100644 index 0000000000..6061db7964 --- /dev/null +++ b/apps/herbatika/src/lib/storefront/cms-homepage-promo.ts @@ -0,0 +1,37 @@ +import "server-only" + +import type { HomepagePromoContent } from "@/components/homepage/homepage.data.types" +import { resolveCmsMediaUrl } from "./cms-client" +import { fetchCmsPageBySlug } from "./cms-pages" +import type { CmsMedia, CmsPage } from "./cms-types" + +export const HOMEPAGE_PROMO_PAGE_SLUG = "homepage-promo" + +const getMediaAlt = (media: CmsMedia | string | null | undefined) => + typeof media === "object" ? (media?.alt?.trim() ?? undefined) : undefined + +export const mapCmsPageToHomepagePromo = ( + page: CmsPage | null +): HomepagePromoContent | null => { + const heading = page?.title?.trim() + const contentHtml = page?.content?.trim() + + if (!(heading && contentHtml)) { + return null + } + + const image = page?.meta?.image + const imageSrc = resolveCmsMediaUrl(image) ?? undefined + + return { + contentHtml, + heading, + imageAlt: getMediaAlt(image), + imageSrc, + } +} + +export const fetchCmsHomepagePromo = async () => + mapCmsPageToHomepagePromo( + await fetchCmsPageBySlug(HOMEPAGE_PROMO_PAGE_SLUG) + ) diff --git a/apps/herbatika/src/lib/storefront/cms-types.ts b/apps/herbatika/src/lib/storefront/cms-types.ts index bcdcae7afa..0a8f2676d5 100644 --- a/apps/herbatika/src/lib/storefront/cms-types.ts +++ b/apps/herbatika/src/lib/storefront/cms-types.ts @@ -45,6 +45,7 @@ export type CmsPage = { id: number | string meta?: { description?: string | null + image?: CmsMedia | string | null title?: string | null } | null publishedDate?: string | null diff --git a/apps/herbatika/src/lib/storefront/cms.ts b/apps/herbatika/src/lib/storefront/cms.ts index 4d33c3cdac..ca1859563d 100644 --- a/apps/herbatika/src/lib/storefront/cms.ts +++ b/apps/herbatika/src/lib/storefront/cms.ts @@ -9,6 +9,10 @@ import { fetchCmsHeroBanners as fetchCmsHeroBannersValue, mapCmsHeroCarouselToHeroBanner as mapCmsHeroCarouselToHeroBannerValue, } from "./cms-hero-carousels" +import { + fetchCmsHomepagePromo as fetchCmsHomepagePromoValue, + HOMEPAGE_PROMO_PAGE_SLUG as HOMEPAGE_PROMO_PAGE_SLUG_VALUE, +} from "./cms-homepage-promo" import { fetchCmsPageBySlug as fetchCmsPageBySlugValue } from "./cms-pages" import type { CmsArticleCategory as CmsArticleCategoryValue, @@ -26,7 +30,9 @@ export const fetchCmsArticleCategories = fetchCmsArticleCategoriesValue export const fetchCmsBlogPost = fetchCmsBlogPostValue export const fetchCmsBlogPosts = fetchCmsBlogPostsValue export const fetchCmsHeroBanners = fetchCmsHeroBannersValue +export const fetchCmsHomepagePromo = fetchCmsHomepagePromoValue export const fetchCmsPageBySlug = fetchCmsPageBySlugValue +export const HOMEPAGE_PROMO_PAGE_SLUG = HOMEPAGE_PROMO_PAGE_SLUG_VALUE export const mapCmsArticleToBlogPost = mapCmsArticleToBlogPostValue export const mapCmsHeroCarouselToHeroBanner = mapCmsHeroCarouselToHeroBannerValue From ea90daa80c2acaddd51079af6c6bee8cc9c5e86f Mon Sep 17 00:00:00 2001 From: KaiUweCZE Date: Tue, 4 Aug 2026 14:40:53 +0200 Subject: [PATCH 2/4] fix(herbatika): scroll to homepage promo after redirect --- apps/herbatika/next.config.ts | 9 +++++++++ apps/herbatika/src/app/[slug]/page.tsx | 20 +------------------ .../sections/homepage-promo-section.tsx | 16 ++++++++++++++- .../src/lib/storefront/cms-homepage-promo.ts | 2 +- apps/herbatika/src/lib/storefront/cms.ts | 2 -- .../src/styles/tokens/_herbatika-spacing.css | 5 +++++ 6 files changed, 31 insertions(+), 23 deletions(-) diff --git a/apps/herbatika/next.config.ts b/apps/herbatika/next.config.ts index d28496fce1..ccda0ba1f6 100644 --- a/apps/herbatika/next.config.ts +++ b/apps/herbatika/next.config.ts @@ -70,6 +70,15 @@ const nextConfig: NextConfig = { ], reactCompiler: true, cacheComponents: true, + async redirects() { + return [ + { + source: "/homepage-promo", + destination: "/#homepage-promo", + permanent: false, + }, + ] + }, outputFileTracingRoot: join(__dirname, "../../"), outputFileTracingExcludes: { "*": [ diff --git a/apps/herbatika/src/app/[slug]/page.tsx b/apps/herbatika/src/app/[slug]/page.tsx index 7180e772d1..c4f2e7c7b8 100644 --- a/apps/herbatika/src/app/[slug]/page.tsx +++ b/apps/herbatika/src/app/[slug]/page.tsx @@ -1,10 +1,7 @@ import type { Metadata } from "next" import { notFound } from "next/navigation" import { CmsPageSurface } from "@/components/cms/cms-page-surface" -import { - fetchCmsPageBySlug, - HOMEPAGE_PROMO_PAGE_SLUG, -} from "@/lib/storefront/cms" +import { fetchCmsPageBySlug } from "@/lib/storefront/cms" type CmsPageRouteProps = { params: Promise<{ @@ -16,16 +13,6 @@ export async function generateMetadata({ params, }: CmsPageRouteProps): Promise { const { slug } = await params - - if (slug === HOMEPAGE_PROMO_PAGE_SLUG) { - return { - robots: { - follow: false, - index: false, - }, - } - } - const page = await fetchCmsPageBySlug(slug) if (!page) { @@ -40,11 +27,6 @@ export async function generateMetadata({ export default async function CmsPageRoute({ params }: CmsPageRouteProps) { const { slug } = await params - - if (slug === HOMEPAGE_PROMO_PAGE_SLUG) { - notFound() - } - const page = await fetchCmsPageBySlug(slug) if (!page) { diff --git a/apps/herbatika/src/components/homepage/sections/homepage-promo-section.tsx b/apps/herbatika/src/components/homepage/sections/homepage-promo-section.tsx index cd83b760e2..6d0170875f 100644 --- a/apps/herbatika/src/components/homepage/sections/homepage-promo-section.tsx +++ b/apps/herbatika/src/components/homepage/sections/homepage-promo-section.tsx @@ -1,7 +1,10 @@ import NextImage from "next/image" +import { useEffect, useRef } from "react" import type { HomepagePromoContent } from "@/components/homepage/homepage.data.types" import { sanitizeHtml } from "@/components/product-detail/utils/html-sanitizer" +const HOMEPAGE_PROMO_SECTION_ID = "homepage-promo" + const DEFAULT_IMAGE = { alt: "Predajňa Herbatika", src: "https://images.unsplash.com/photo-1600093463592-8e36ae95ef56?auto=format&fit=crop&w=1100&q=80", @@ -12,12 +15,23 @@ type HomepagePromoSectionProps = { } export function HomepagePromoSection({ promo }: HomepagePromoSectionProps) { + const sectionRef = useRef(null) const imageAlt = promo?.imageAlt || DEFAULT_IMAGE.alt const imageSrc = promo?.imageSrc || DEFAULT_IMAGE.src const contentHtml = sanitizeHtml(promo?.contentHtml ?? "") + useEffect(() => { + if (window.location.hash === `#${HOMEPAGE_PROMO_SECTION_ID}`) { + sectionRef.current?.scrollIntoView({ block: "start" }) + } + }, []) + return ( -
+
typeof media === "object" ? (media?.alt?.trim() ?? undefined) : undefined diff --git a/apps/herbatika/src/lib/storefront/cms.ts b/apps/herbatika/src/lib/storefront/cms.ts index ca1859563d..00b28bbf7a 100644 --- a/apps/herbatika/src/lib/storefront/cms.ts +++ b/apps/herbatika/src/lib/storefront/cms.ts @@ -11,7 +11,6 @@ import { } from "./cms-hero-carousels" import { fetchCmsHomepagePromo as fetchCmsHomepagePromoValue, - HOMEPAGE_PROMO_PAGE_SLUG as HOMEPAGE_PROMO_PAGE_SLUG_VALUE, } from "./cms-homepage-promo" import { fetchCmsPageBySlug as fetchCmsPageBySlugValue } from "./cms-pages" import type { @@ -32,7 +31,6 @@ export const fetchCmsBlogPosts = fetchCmsBlogPostsValue export const fetchCmsHeroBanners = fetchCmsHeroBannersValue export const fetchCmsHomepagePromo = fetchCmsHomepagePromoValue export const fetchCmsPageBySlug = fetchCmsPageBySlugValue -export const HOMEPAGE_PROMO_PAGE_SLUG = HOMEPAGE_PROMO_PAGE_SLUG_VALUE export const mapCmsArticleToBlogPost = mapCmsArticleToBlogPostValue export const mapCmsHeroCarouselToHeroBanner = mapCmsHeroCarouselToHeroBannerValue diff --git a/apps/herbatika/src/styles/tokens/_herbatika-spacing.css b/apps/herbatika/src/styles/tokens/_herbatika-spacing.css index 12b430b064..3c38c86710 100644 --- a/apps/herbatika/src/styles/tokens/_herbatika-spacing.css +++ b/apps/herbatika/src/styles/tokens/_herbatika-spacing.css @@ -36,6 +36,11 @@ --spacing-homepage: var(--spacing-page-default); --spacing-homepage-lg: var(--spacing-page); --spacing-homepage-gap: var(--spacing-page-gap); + --spacing-homepage-promo-scroll-offset: calc( + var(--height-header-mobile) + + var(--height-search-form) + + var(--spacing-300) + ); --spacing-category-page: var(--spacing-page-default); --spacing-category-page-lg: var(--spacing-page); From d97dc6351fec2820749d20c2a0337b652ab82792 Mon Sep 17 00:00:00 2001 From: KaiUweCZE Date: Tue, 4 Aug 2026 15:41:23 +0200 Subject: [PATCH 3/4] fix(herbatika): preserve CMS formatting in homepage promo --- .../sections/homepage-promo-html.test.ts | 50 ++++++++++++++++ .../homepage/sections/homepage-promo-html.ts | 35 +++++++++++ .../sections/homepage-promo-section.tsx | 8 +-- .../product-detail/utils/html-sanitizer.ts | 59 +++++++++++++++---- 4 files changed, 137 insertions(+), 15 deletions(-) create mode 100644 apps/herbatika/src/components/homepage/sections/homepage-promo-html.test.ts create mode 100644 apps/herbatika/src/components/homepage/sections/homepage-promo-html.ts diff --git a/apps/herbatika/src/components/homepage/sections/homepage-promo-html.test.ts b/apps/herbatika/src/components/homepage/sections/homepage-promo-html.test.ts new file mode 100644 index 0000000000..edc5ecb2ce --- /dev/null +++ b/apps/herbatika/src/components/homepage/sections/homepage-promo-html.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest" +import { sanitizeHomepagePromoHtml } from "./homepage-promo-html" + +describe("sanitizeHomepagePromoHtml", () => { + it("preserves the rich-text formatting enabled for Payload pages", () => { + const html = ` +

Heading 1

Heading 5
Heading 6
+

code H2O x2

+

removed

+

important

+
+
    + +
+ ` + + const result = sanitizeHomepagePromoHtml(html) + + expect(result).toContain("

Heading 1

") + expect(result).toContain("
Heading 5
") + expect(result).toContain("
Heading 6
") + expect(result).toContain("code") + expect(result).toContain("H2O x2") + expect(result).toContain( + 'removed' + ) + expect(result).toContain( + 'important' + ) + expect(result).toContain("
") + expect(result).toContain('
  • ') + expect(result).not.toContain(" { + const result = sanitizeHomepagePromoHtml(` +

    Heading

    + Text +
  • Item
  • + + `) + + expect(result).toBe( + "

    Heading

    \n Text\n
  • Item
  • " + ) + }) +}) diff --git a/apps/herbatika/src/components/homepage/sections/homepage-promo-html.ts b/apps/herbatika/src/components/homepage/sections/homepage-promo-html.ts new file mode 100644 index 0000000000..ab90531eba --- /dev/null +++ b/apps/herbatika/src/components/homepage/sections/homepage-promo-html.ts @@ -0,0 +1,35 @@ +import { + sanitizeHtml, + type SanitizeHtmlOptions, +} from "@/components/product-detail/utils/html-sanitizer" + +const HOMEPAGE_PROMO_SANITIZE_OPTIONS = { + additionalAllowedAttributeValues: { + li: { + "aria-checked": new Set(["false", "true"]), + role: new Set(["checkbox"]), + }, + span: { + style: new Set([ + "text-decoration: line-through;", + "text-decoration: underline;", + ]), + }, + }, + additionalAllowedTagAttributes: { + li: new Set(["aria-checked", "role"]), + span: new Set(["style"]), + }, + additionalAllowedTags: new Set([ + "code", + "h1", + "h5", + "h6", + "hr", + "sub", + "sup", + ]), +} satisfies SanitizeHtmlOptions + +export const sanitizeHomepagePromoHtml = (html: string): string => + sanitizeHtml(html, HOMEPAGE_PROMO_SANITIZE_OPTIONS) diff --git a/apps/herbatika/src/components/homepage/sections/homepage-promo-section.tsx b/apps/herbatika/src/components/homepage/sections/homepage-promo-section.tsx index 6d0170875f..b5edaec770 100644 --- a/apps/herbatika/src/components/homepage/sections/homepage-promo-section.tsx +++ b/apps/herbatika/src/components/homepage/sections/homepage-promo-section.tsx @@ -1,7 +1,7 @@ import NextImage from "next/image" import { useEffect, useRef } from "react" import type { HomepagePromoContent } from "@/components/homepage/homepage.data.types" -import { sanitizeHtml } from "@/components/product-detail/utils/html-sanitizer" +import { sanitizeHomepagePromoHtml } from "./homepage-promo-html" const HOMEPAGE_PROMO_SECTION_ID = "homepage-promo" @@ -18,7 +18,7 @@ export function HomepagePromoSection({ promo }: HomepagePromoSectionProps) { const sectionRef = useRef(null) const imageAlt = promo?.imageAlt || DEFAULT_IMAGE.alt const imageSrc = promo?.imageSrc || DEFAULT_IMAGE.src - const contentHtml = sanitizeHtml(promo?.contentHtml ?? "") + const contentHtml = sanitizeHomepagePromoHtml(promo?.contentHtml ?? "") useEffect(() => { if (window.location.hash === `#${HOMEPAGE_PROMO_SECTION_ID}`) { @@ -50,8 +50,8 @@ export function HomepagePromoSection({ promo }: HomepagePromoSectionProps) { {contentHtml ? (
    ) : ( diff --git a/apps/herbatika/src/components/product-detail/utils/html-sanitizer.ts b/apps/herbatika/src/components/product-detail/utils/html-sanitizer.ts index ddd8ab2180..b7ef578264 100644 --- a/apps/herbatika/src/components/product-detail/utils/html-sanitizer.ts +++ b/apps/herbatika/src/components/product-detail/utils/html-sanitizer.ts @@ -41,6 +41,16 @@ const ALLOWED_TAG_ATTRIBUTES: Record> = { th: new Set(["colspan", "rowspan", "title"]), } +type AllowedAttributeValues = Readonly< + Record>>> +> + +export type SanitizeHtmlOptions = { + additionalAllowedAttributeValues?: AllowedAttributeValues + additionalAllowedTagAttributes?: Readonly>> + additionalAllowedTags?: ReadonlySet +} + const SAFE_ANCHOR_HREF_REGEX = /^(https?:|mailto:|tel:|\/|#)/i const SAFE_IMAGE_SRC_REGEX = /^(https?:|\/)/i const HTTP_URL_REGEX = /^https?:/i @@ -87,9 +97,24 @@ const parseTagAttributes = (rawAttributes: string) => { } const isAttributeAllowed = ( + tag: string, name: string, - allowedAttributesForTag: Set -) => ALLOWED_GLOBAL_ATTRIBUTES.has(name) || allowedAttributesForTag.has(name) + value: string, + allowedAttributesForTag: Set, + options: SanitizeHtmlOptions +) => { + if ( + ALLOWED_GLOBAL_ATTRIBUTES.has(name) || + allowedAttributesForTag.has(name) + ) { + return true + } + + return Boolean( + options.additionalAllowedTagAttributes?.[tag]?.has(name) && + options.additionalAllowedAttributeValues?.[tag]?.[name]?.has(value) + ) +} type SanitizedAttributeState = { attributes: string[] @@ -220,7 +245,11 @@ const appendImageAttributes = (state: SanitizedAttributeState) => { return true } -const sanitizeOpeningTag = (tag: string, rawAttributes: string) => { +const sanitizeOpeningTag = ( + tag: string, + rawAttributes: string, + options: SanitizeHtmlOptions +) => { const allowedAttributesForTag = ALLOWED_TAG_ATTRIBUTES[tag] ?? new Set() const state = createAttributeState() @@ -228,7 +257,9 @@ const sanitizeOpeningTag = (tag: string, rawAttributes: string) => { for (const attribute of parseTagAttributes(rawAttributes ?? "")) { const { name, value } = attribute - if (!isAttributeAllowed(name, allowedAttributesForTag)) { + if ( + !isAttributeAllowed(tag, name, value, allowedAttributesForTag, options) + ) { continue } @@ -246,7 +277,7 @@ const sanitizeOpeningTag = (tag: string, rawAttributes: string) => { const attributesString = state.attributes.length > 0 ? ` ${state.attributes.join(" ")}` : "" - if (tag === "br" || tag === "img") { + if (tag === "br" || tag === "hr" || tag === "img") { return `<${tag}${attributesString}>` } @@ -258,22 +289,28 @@ const sanitizeOpeningTag = (tag: string, rawAttributes: string) => { const sanitizeHtmlTag = ( closingSlash: string, rawTag: string, - rawAttributes: string + rawAttributes: string, + options: SanitizeHtmlOptions ) => { const tag = rawTag.toLowerCase() - if (!ALLOWED_HTML_TAGS.has(tag)) { + if ( + !(ALLOWED_HTML_TAGS.has(tag) || options.additionalAllowedTags?.has(tag)) + ) { return "" } if (closingSlash === "/") { - return tag === "br" || tag === "img" ? "" : `` + return tag === "br" || tag === "hr" || tag === "img" ? "" : `` } - return sanitizeOpeningTag(tag, rawAttributes) + return sanitizeOpeningTag(tag, rawAttributes, options) } -export const sanitizeHtml = (html: string): string => { +export const sanitizeHtml = ( + html: string, + options: SanitizeHtmlOptions = {} +): string => { if (!html) { return "" } @@ -289,7 +326,7 @@ export const sanitizeHtml = (html: string): string => { const sanitized = cleanedHtml.replace( /<\s*(\/?)\s*([a-zA-Z0-9]+)([^>]*)>/g, (_, closingSlash: string, rawTag: string, rawAttributes: string) => - sanitizeHtmlTag(closingSlash, rawTag, rawAttributes) + sanitizeHtmlTag(closingSlash, rawTag, rawAttributes, options) ) return sanitized.trim() From ee88006a9a50e85702da4ce343d85fefd25e6da1 Mon Sep 17 00:00:00 2001 From: KaiUweCZE Date: Tue, 4 Aug 2026 17:00:10 +0200 Subject: [PATCH 4/4] style(herbatika): align homepage promo formatting --- .../src/components/homepage/sections/homepage-promo-html.ts | 2 +- apps/herbatika/src/styles/tokens/_herbatika-spacing.css | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/herbatika/src/components/homepage/sections/homepage-promo-html.ts b/apps/herbatika/src/components/homepage/sections/homepage-promo-html.ts index ab90531eba..6d2b30d8fe 100644 --- a/apps/herbatika/src/components/homepage/sections/homepage-promo-html.ts +++ b/apps/herbatika/src/components/homepage/sections/homepage-promo-html.ts @@ -1,6 +1,6 @@ import { - sanitizeHtml, type SanitizeHtmlOptions, + sanitizeHtml, } from "@/components/product-detail/utils/html-sanitizer" const HOMEPAGE_PROMO_SANITIZE_OPTIONS = { diff --git a/apps/herbatika/src/styles/tokens/_herbatika-spacing.css b/apps/herbatika/src/styles/tokens/_herbatika-spacing.css index 3c38c86710..3b4c9d92d3 100644 --- a/apps/herbatika/src/styles/tokens/_herbatika-spacing.css +++ b/apps/herbatika/src/styles/tokens/_herbatika-spacing.css @@ -37,9 +37,9 @@ --spacing-homepage-lg: var(--spacing-page); --spacing-homepage-gap: var(--spacing-page-gap); --spacing-homepage-promo-scroll-offset: calc( - var(--height-header-mobile) + - var(--height-search-form) + - var(--spacing-300) + var(--height-header-mobile) + + var(--height-search-form) + + var(--spacing-300) ); --spacing-category-page: var(--spacing-page-default);