From ef24e79c39c3961959db0f89c0ad780b253cb66d Mon Sep 17 00:00:00 2001 From: kipsang Date: Thu, 21 May 2026 16:26:19 +0300 Subject: [PATCH 1/4] test: add unit tests for homepage and page builder components - Introduced tests for FeaturedCollectionsSection to verify rendering of categories and curated items. - Added tests for PageSectionsRenderer to ensure all supported section types are rendered correctly. - Created tests for TestimonialsSection to validate rendering of testimonial cards with optional ratings and meta. - Implemented tests for homepage configuration functions to ensure correct behavior when building homepage sections from tenant layout pages and handling various configurations. - Added tests for dynamic pages configuration to validate merging of root pages and handling of invalid input. - Included tests for tenant announcement bar and resolvers to ensure proper configuration resolution and fallback behavior. --- .../[locale]/(storefront)/[...slug]/page.tsx | 69 ++++++ .../(storefront)/__tests__/layout.test.tsx | 147 ++++++++++++ .../(storefront)/__tests__/page.test.tsx | 141 +++++++++++ .../[locale]/(storefront)/layout.tsx | 13 +- .../[country]/[locale]/(storefront)/page.tsx | 41 +--- src/app/[country]/[locale]/loading.test.tsx | 12 + src/app/[country]/[locale]/loading.tsx | 13 +- src/components/layout/AnnouncementBar.tsx | 44 ++++ .../layout/__tests__/AnnouncementBar.test.tsx | 92 +++++++ src/components/page-builder/FaqSection.tsx | 69 ++++++ .../FeaturedCollectionsSection.tsx | 222 +++++++++++++++++ .../page-builder/PageSectionsRenderer.tsx | 15 ++ .../page-builder/TestimonialsSection.tsx | 93 +++++++ .../__tests__/FaqSection.test.tsx | 37 +++ .../FeaturedCollectionsSection.test.tsx | 131 ++++++++++ .../__tests__/PageSectionsRenderer.test.tsx | 108 +++++++++ .../__tests__/TestimonialsSection.test.tsx | 40 ++++ src/lib/homepage/index.test.ts | 226 ++++++++++++++++++ src/lib/homepage/index.ts | 183 +++++++++++++- src/lib/homepage/types.ts | 54 ++++- src/lib/page-builder/index.test.ts | 200 ++++++++++++++++ src/lib/page-builder/index.ts | 16 +- src/lib/page-builder/types.ts | 6 + src/lib/spree/config.ts | 34 ++- src/lib/tenant/__tests__/announcement.test.ts | 76 ++++++ src/lib/tenant/__tests__/resolvers.test.ts | 41 ++++ src/lib/tenant/announcement.ts | 88 +++++++ src/lib/tenant/index.ts | 1 + src/lib/tenant/normalize.ts | 28 ++- src/lib/tenant/resolvers.ts | 15 +- src/lib/tenant/surface.ts | 30 ++- 31 files changed, 2211 insertions(+), 74 deletions(-) create mode 100644 src/app/[country]/[locale]/(storefront)/[...slug]/page.tsx create mode 100644 src/app/[country]/[locale]/(storefront)/__tests__/layout.test.tsx create mode 100644 src/app/[country]/[locale]/(storefront)/__tests__/page.test.tsx create mode 100644 src/app/[country]/[locale]/loading.test.tsx create mode 100644 src/components/layout/AnnouncementBar.tsx create mode 100644 src/components/layout/__tests__/AnnouncementBar.test.tsx create mode 100644 src/components/page-builder/FaqSection.tsx create mode 100644 src/components/page-builder/FeaturedCollectionsSection.tsx create mode 100644 src/components/page-builder/TestimonialsSection.tsx create mode 100644 src/components/page-builder/__tests__/FaqSection.test.tsx create mode 100644 src/components/page-builder/__tests__/FeaturedCollectionsSection.test.tsx create mode 100644 src/components/page-builder/__tests__/PageSectionsRenderer.test.tsx create mode 100644 src/components/page-builder/__tests__/TestimonialsSection.test.tsx create mode 100644 src/lib/homepage/index.test.ts create mode 100644 src/lib/page-builder/index.test.ts create mode 100644 src/lib/tenant/__tests__/announcement.test.ts create mode 100644 src/lib/tenant/__tests__/resolvers.test.ts create mode 100644 src/lib/tenant/announcement.ts diff --git a/src/app/[country]/[locale]/(storefront)/[...slug]/page.tsx b/src/app/[country]/[locale]/(storefront)/[...slug]/page.tsx new file mode 100644 index 00000000..50722937 --- /dev/null +++ b/src/app/[country]/[locale]/(storefront)/[...slug]/page.tsx @@ -0,0 +1,69 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { DynamicPageRenderer } from "@/components/page-builder/DynamicPageRenderer"; +import { resolveCurrency } from "@/lib/data/markets"; +import { getDynamicPagesConfig, resolveDynamicPage } from "@/lib/page-builder"; +import { getStoreName } from "@/lib/store"; +import { getTenantConfigFromRequest } from "@/lib/tenant/request"; +import { getTenantBrandName } from "@/lib/tenant/surface"; + +interface DynamicStorePageProps { + params: Promise<{ + country: string; + locale: string; + slug: string[]; + }>; +} + +export async function generateMetadata({ + params, +}: DynamicStorePageProps): Promise { + const { slug } = await params; + const tenantConfig = await getTenantConfigFromRequest(); + const storeName = getTenantBrandName(tenantConfig) ?? getStoreName(); + const dynamicPagesConfig = getDynamicPagesConfig( + { storeName }, + tenantConfig?.raw, + ); + const page = resolveDynamicPage(dynamicPagesConfig, slug); + + if (!page) { + return { + title: storeName, + }; + } + + return { + title: page.seo?.title ?? page.title, + description: page.seo?.description ?? page.description, + }; +} + +export default async function DynamicStorePage({ + params, +}: DynamicStorePageProps) { + const { country, locale, slug } = await params; + const basePath = `/${country}/${locale}`; + const currency = await resolveCurrency(country); + const tenantConfig = await getTenantConfigFromRequest(); + const storeName = getTenantBrandName(tenantConfig) ?? getStoreName(); + const dynamicPagesConfig = getDynamicPagesConfig( + { storeName }, + tenantConfig?.raw, + ); + const page = resolveDynamicPage(dynamicPagesConfig, slug); + + if (!page) { + notFound(); + } + + return ( + + ); +} diff --git a/src/app/[country]/[locale]/(storefront)/__tests__/layout.test.tsx b/src/app/[country]/[locale]/(storefront)/__tests__/layout.test.tsx new file mode 100644 index 00000000..dad9524a --- /dev/null +++ b/src/app/[country]/[locale]/(storefront)/__tests__/layout.test.tsx @@ -0,0 +1,147 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("next/headers", () => ({ + headers: vi.fn().mockResolvedValue(new Headers()), +})); + +vi.mock("@/components/layout/AnnouncementBar", () => ({ + AnnouncementBar: ({ + announcementBar, + }: { + announcementBar: { message: string }; + }) =>
{announcementBar.message}
, +})); + +vi.mock("@/components/layout/Header", () => ({ + Header: () =>
header
, +})); + +vi.mock("@/components/layout/Footer", () => ({ + Footer: () =>
footer
, +})); + +vi.mock("@/lib/data/categories", () => ({ + getCategories: vi.fn().mockResolvedValue({ data: [] }), +})); + +vi.mock("@/lib/tenant", () => ({ + getTenantConfigByHost: vi.fn().mockResolvedValue({ raw: {} }), + resolveTenantAnnouncementBar: vi.fn().mockReturnValue({ + message: "Free delivery this week", + linkLabel: "Shop now", + href: "/products", + }), +})); + +vi.mock("@/lib/tenant/request", () => ({ + getRequestHost: vi.fn().mockReturnValue("aurora.example.com"), +})); + +import { getCategories } from "@/lib/data/categories"; +import { + getTenantConfigByHost, + resolveTenantAnnouncementBar, +} from "@/lib/tenant"; +import StorefrontLayout from "../layout"; + +const mockGetCategories = vi.mocked(getCategories); +const mockGetTenantConfigByHost = vi.mocked(getTenantConfigByHost); +const mockResolveTenantAnnouncementBar = vi.mocked( + resolveTenantAnnouncementBar, +); + +describe("StorefrontLayout", () => { + it("renders the global announcement bar before the storefront chrome", async () => { + mockGetCategories.mockResolvedValueOnce({ data: [] } as never); + mockGetTenantConfigByHost.mockResolvedValueOnce({ raw: {} } as never); + mockResolveTenantAnnouncementBar.mockReturnValueOnce({ + message: "Free delivery this week", + linkLabel: "Shop now", + href: "/products", + }); + + const element = await StorefrontLayout({ + children:
Page body
, + params: Promise.resolve({ country: "ke", locale: "en" }), + }); + + render(element); + + expect(screen.getByTestId("announcement-bar")).toHaveTextContent( + "Free delivery this week", + ); + expect(screen.getByTestId("header")).toBeInTheDocument(); + expect(screen.getByText("Page body")).toBeInTheDocument(); + expect(screen.getByTestId("footer")).toBeInTheDocument(); + }); + + it("renders category navigation when categories exist and no announcement is configured", async () => { + mockGetCategories.mockResolvedValueOnce({ + data: [ + { + id: "root-1", + name: "Living Room", + permalink: "living-room", + children: [ + { + id: "child-1", + name: "Lighting", + permalink: "lighting", + children: [], + }, + ], + }, + ], + } as never); + mockGetTenantConfigByHost.mockResolvedValueOnce({ raw: {} } as never); + mockResolveTenantAnnouncementBar.mockReturnValueOnce(null); + + const element = await StorefrontLayout({ + children:
Category page
, + params: Promise.resolve({ country: "ke", locale: "en" }), + }); + + render(element); + + expect(screen.queryByTestId("announcement-bar")).not.toBeInTheDocument(); + expect( + screen.getByRole("navigation", { name: "Category navigation" }), + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Living Room" })).toHaveAttribute( + "href", + "/ke/en/c/living-room", + ); + expect(screen.getByRole("link", { name: "Lighting" })).toHaveAttribute( + "href", + "/ke/en/c/lighting", + ); + }); + + it("falls back to an empty category list when category loading fails", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + + mockGetCategories.mockRejectedValueOnce(new Error("boom")); + mockGetTenantConfigByHost.mockResolvedValueOnce({ raw: {} } as never); + mockResolveTenantAnnouncementBar.mockReturnValueOnce(null); + + const element = await StorefrontLayout({ + children:
Fallback page
, + params: Promise.resolve({ country: "ke", locale: "en" }), + }); + + render(element); + + expect( + screen.queryByRole("navigation", { name: "Category navigation" }), + ).not.toBeInTheDocument(); + expect(consoleError).toHaveBeenCalledWith( + "StorefrontLayout: failed to load categories", + expect.any(Error), + ); + + consoleError.mockRestore(); + }); +}); diff --git a/src/app/[country]/[locale]/(storefront)/__tests__/page.test.tsx b/src/app/[country]/[locale]/(storefront)/__tests__/page.test.tsx new file mode 100644 index 00000000..7466e31f --- /dev/null +++ b/src/app/[country]/[locale]/(storefront)/__tests__/page.test.tsx @@ -0,0 +1,141 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/page-builder/PageSectionsRenderer", () => ({ + PageSectionsRenderer: ({ + sections, + }: { + sections: Array<{ type: string; title?: string }>; + }) => ( +
+ {sections.map((section, index) => ( + + {section.title ?? section.type} + + ))} +
+ ), +})); + +vi.mock("@/lib/data/markets", () => ({ + getMarkets: vi.fn(), + resolveCurrency: vi.fn().mockResolvedValue("USD"), +})); + +vi.mock("@/lib/homepage", () => ({ + getHomepageConfig: vi.fn(), + getHomepageSections: vi.fn(), +})); + +vi.mock("@/lib/metadata/home", () => ({ + generateHomeMetadata: vi.fn(), +})); + +vi.mock("@/lib/store", () => ({ + getDefaultCountry: vi.fn().mockReturnValue("us"), + getDefaultLocale: vi.fn().mockReturnValue("en"), + getStoreName: vi.fn().mockReturnValue("Default Store"), +})); + +vi.mock("@/lib/tenant/request", () => ({ + getTenantConfigFromRequest: vi.fn(), +})); + +vi.mock("@/lib/tenant/surface", () => ({ + getTenantBrandName: vi.fn().mockReturnValue("Jishop"), +})); + +import { getMarkets } from "@/lib/data/markets"; +import { getHomepageConfig, getHomepageSections } from "@/lib/homepage"; +import { generateHomeMetadata } from "@/lib/metadata/home"; +import { getTenantConfigFromRequest } from "@/lib/tenant/request"; +import HomePage, { generateMetadata, generateStaticParams } from "../page"; + +const mockGetMarkets = vi.mocked(getMarkets); +const mockGetHomepageConfig = vi.mocked(getHomepageConfig); +const mockGetHomepageSections = vi.mocked(getHomepageSections); +const mockGenerateHomeMetadata = vi.mocked(generateHomeMetadata); +const mockGetTenantConfigFromRequest = vi.mocked(getTenantConfigFromRequest); + +describe("HomePage", () => { + it("delegates homepage sections to the shared renderer in order", async () => { + mockGetTenantConfigFromRequest.mockResolvedValue({ + raw: {}, + } as never); + mockGetHomepageConfig.mockReturnValue({ + version: 1, + sections: [ + { type: "hero", title: "Hero" }, + { type: "features", title: "Features A", items: [] }, + { type: "featured-collections", title: "Collections" }, + { + type: "faq", + title: "Questions", + items: [], + }, + ], + } as never); + mockGetHomepageSections.mockReturnValue([ + { type: "hero", title: "Hero" }, + { type: "features", title: "Features A", items: [] }, + { type: "featured-collections", title: "Collections" }, + { type: "faq", title: "Questions", items: [] }, + ] as never); + + const element = await HomePage({ + params: Promise.resolve({ country: "ke", locale: "en" }), + }); + + render(element); + + expect(screen.getByText("Hero")).toBeInTheDocument(); + expect(screen.getByText("Features A")).toBeInTheDocument(); + expect(screen.getByText("Collections")).toBeInTheDocument(); + expect(screen.getByText("Questions")).toBeInTheDocument(); + expect(screen.getByTestId("page-sections-renderer")).toBeInTheDocument(); + }); + + it("returns fallback static params when markets fail", async () => { + mockGetMarkets.mockRejectedValue(new Error("boom")); + + await expect(generateStaticParams()).resolves.toEqual([ + { country: "us", locale: "en" }, + ]); + }); + + it("deduplicates market country and locale pairs", async () => { + mockGetMarkets.mockResolvedValue({ + data: [ + { + default_locale: "en", + countries: [{ iso: "US" }, { iso: "KE" }, { iso: "US" }], + }, + { + default_locale: "fr", + countries: [{ iso: "FR" }], + }, + ], + } as never); + + await expect(generateStaticParams()).resolves.toEqual([ + { country: "us", locale: "en" }, + { country: "ke", locale: "en" }, + { country: "fr", locale: "fr" }, + ]); + }); + + it("proxies homepage metadata generation", async () => { + mockGenerateHomeMetadata.mockResolvedValue({ title: "Home" } as never); + + await expect( + generateMetadata({ + params: Promise.resolve({ country: "ke", locale: "en" }), + }), + ).resolves.toEqual({ title: "Home" }); + + expect(mockGenerateHomeMetadata).toHaveBeenCalledWith({ + country: "ke", + locale: "en", + }); + }); +}); diff --git a/src/app/[country]/[locale]/(storefront)/layout.tsx b/src/app/[country]/[locale]/(storefront)/layout.tsx index b61ad28c..c97eb199 100644 --- a/src/app/[country]/[locale]/(storefront)/layout.tsx +++ b/src/app/[country]/[locale]/(storefront)/layout.tsx @@ -1,10 +1,14 @@ import type { Category } from "@spree/sdk"; import { headers } from "next/headers"; import Link from "next/link"; +import { AnnouncementBar } from "@/components/layout/AnnouncementBar"; import { Footer } from "@/components/layout/Footer"; import { Header } from "@/components/layout/Header"; import { getCategories } from "@/lib/data/categories"; -import { getTenantConfigByHost } from "@/lib/tenant"; +import { + getTenantConfigByHost, + resolveTenantAnnouncementBar, +} from "@/lib/tenant"; import { getRequestHost } from "@/lib/tenant/request"; interface StorefrontLayoutProps { @@ -44,6 +48,7 @@ export default async function StorefrontLayout({ const requestHeaders = await headers(); const tenantHost = getRequestHost(requestHeaders) ?? "localhost"; const tenantConfig = await getTenantConfigByHost(tenantHost); + const announcementBar = resolveTenantAnnouncementBar(tenantConfig); const rootCategories = await getCategories({ depth_eq: 0, @@ -57,6 +62,12 @@ export default async function StorefrontLayout({ return ( <> + {announcementBar ? ( + + ) : null}
- {sections.map((section) => { - switch (section.type) { - case "hero": - return ( - - ); - case "features": - return ; - case "featured-products": - return ( - - ); - default: - return null; - } - })} - + ); } diff --git a/src/app/[country]/[locale]/loading.test.tsx b/src/app/[country]/[locale]/loading.test.tsx new file mode 100644 index 00000000..d6a66bfb --- /dev/null +++ b/src/app/[country]/[locale]/loading.test.tsx @@ -0,0 +1,12 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import Loading from "./loading"; + +describe("Locale loading state", () => { + it("renders a static fallback without tenant lookup", () => { + render(); + + expect(screen.getByText("Loading storefront")).toBeInTheDocument(); + }); +}); diff --git a/src/app/[country]/[locale]/loading.tsx b/src/app/[country]/[locale]/loading.tsx index 3e0b10db..1114f049 100644 --- a/src/app/[country]/[locale]/loading.tsx +++ b/src/app/[country]/[locale]/loading.tsx @@ -1,21 +1,12 @@ -import { headers } from "next/headers"; import type { ReactElement } from "react"; -import { getTenantConfigByHost } from "@/lib/tenant/olitt"; -import { getRequestHost } from "@/lib/tenant/request"; -import { getTenantBrandName } from "@/lib/tenant/surface"; - -export default async function Loading(): Promise { - const requestHeaders = await headers(); - const host = getRequestHost(requestHeaders) ?? ""; - const tenantConfig = host ? await getTenantConfigByHost(host) : null; - const storeName = getTenantBrandName(tenantConfig) ?? "Store"; +export default function Loading(): ReactElement { return (

- Loading {storeName} + Loading Store

diff --git a/src/components/layout/AnnouncementBar.tsx b/src/components/layout/AnnouncementBar.tsx new file mode 100644 index 00000000..0719f042 --- /dev/null +++ b/src/components/layout/AnnouncementBar.tsx @@ -0,0 +1,44 @@ +import Link from "next/link"; +import type { CSSProperties } from "react"; +import type { TenantAnnouncementBarConfig } from "@/lib/tenant"; + +interface AnnouncementBarProps { + basePath: string; + announcementBar: TenantAnnouncementBarConfig; +} + +function resolveHref(basePath: string, href: string): string { + if (/^https?:\/\//.test(href)) return href; + if (href.startsWith(basePath)) return href; + if (href === "/") return basePath; + return href.startsWith("/") ? `${basePath}${href}` : `${basePath}/${href}`; +} + +export function AnnouncementBar({ + basePath, + announcementBar, +}: AnnouncementBarProps) { + const barStyle: CSSProperties = { + backgroundColor: announcementBar.backgroundColor, + color: announcementBar.foregroundColor, + }; + const href = announcementBar.href + ? resolveHref(basePath, announcementBar.href) + : undefined; + + return ( +
+
+ {announcementBar.message} + {href ? ( + + {announcementBar.linkLabel} + + ) : null} +
+
+ ); +} diff --git a/src/components/layout/__tests__/AnnouncementBar.test.tsx b/src/components/layout/__tests__/AnnouncementBar.test.tsx new file mode 100644 index 00000000..55aa08df --- /dev/null +++ b/src/components/layout/__tests__/AnnouncementBar.test.tsx @@ -0,0 +1,92 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { AnnouncementBar } from "../AnnouncementBar"; + +describe("AnnouncementBar", () => { + it("renders the announcement message and resolves internal links", () => { + render( + , + ); + + expect( + screen.getByText("Free delivery on Nairobi orders over KES 5,000"), + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Shop now" })).toHaveAttribute( + "href", + "/ke/en/products", + ); + }); + + it("preserves absolute links and supports announcement bars without CTAs", () => { + const { rerender } = render( + , + ); + + expect(screen.getByRole("link", { name: "Get help" })).toHaveAttribute( + "href", + "https://example.com/support", + ); + + rerender( + , + ); + + expect( + screen.queryByRole("link", { name: "Learn more" }), + ).not.toBeInTheDocument(); + }); + + it("handles root and already-prefixed internal paths", () => { + const { rerender } = render( + , + ); + + expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute( + "href", + "/ke/en", + ); + + rerender( + , + ); + + expect(screen.getByRole("link", { name: "Sale" })).toHaveAttribute( + "href", + "/ke/en/products", + ); + }); +}); diff --git a/src/components/page-builder/FaqSection.tsx b/src/components/page-builder/FaqSection.tsx new file mode 100644 index 00000000..47428bc2 --- /dev/null +++ b/src/components/page-builder/FaqSection.tsx @@ -0,0 +1,69 @@ +import type { CSSProperties } from "react"; +import type { HomepageFaqSectionConfig } from "@/lib/homepage"; + +interface FaqSectionProps { + section: HomepageFaqSectionConfig; +} + +export function FaqSection({ section }: FaqSectionProps) { + const theme = section.theme ?? {}; + const sectionStyle: CSSProperties = { + backgroundColor: theme.background, + color: theme.foreground, + }; + const mutedTextColor = theme.mutedForeground ?? "#64748b"; + const cardBackground = theme.cardBackground ?? "rgba(255, 255, 255, 0.72)"; + const borderColor = theme.borderColor ?? "#cbd5e1"; + + return ( +
+
+
+
+ {section.eyebrow ? ( + + {section.eyebrow} + + ) : null} +

+ {section.title} +

+ {section.description ? ( +

+ {section.description} +

+ ) : null} +
+
+ {section.items.map((item, index) => ( +
+ + + + {index + 1} + + {item.question} + + +

+ {item.answer} +

+
+ ))} +
+
+
+
+ ); +} diff --git a/src/components/page-builder/FeaturedCollectionsSection.tsx b/src/components/page-builder/FeaturedCollectionsSection.tsx new file mode 100644 index 00000000..84a9d96c --- /dev/null +++ b/src/components/page-builder/FeaturedCollectionsSection.tsx @@ -0,0 +1,222 @@ +import Link from "next/link"; +import type { CSSProperties } from "react"; +import { Button } from "@/components/ui/button"; +import { getCategories } from "@/lib/data/categories"; +import type { + HomepageCollectionItemConfig, + HomepageFeaturedCollectionsSectionConfig, +} from "@/lib/homepage"; + +interface FeaturedCollectionsSectionProps { + basePath: string; + section: HomepageFeaturedCollectionsSectionConfig; +} + +interface CollectionCard { + title: string; + description: string; + href: string; +} + +function resolveHref(basePath: string, href: string): string { + if (/^https?:\/\//.test(href)) return href; + if (href.startsWith(basePath)) return href; + if (href === "/") return basePath; + return href.startsWith("/") ? `${basePath}${href}` : `${basePath}/${href}`; +} + +function buildCollectionDescription( + title: string, + childCount: number, + override?: string, +): string { + if (override) { + return override; + } + + if (childCount > 0) { + return `${childCount} curated categories ready to explore.`; + } + + return `Browse the latest picks in ${title}.`; +} + +function buildCategoryHref( + basePath: string, + permalink?: string, + href?: string, +) { + if (href) { + return resolveHref(basePath, href); + } + + if (permalink) { + return `${basePath}/c/${permalink}`; + } + + return `${basePath}/products`; +} + +function buildRequestedCards( + basePath: string, + items: HomepageCollectionItemConfig[], + categories: Array<{ + permalink?: string | null; + name?: string | null; + children?: unknown[] | null; + }>, +): CollectionCard[] { + const categoriesByPermalink = new Map( + categories + .filter((category) => Boolean(category.permalink)) + .map((category) => [category.permalink ?? "", category]), + ); + + return items + .map((item) => { + const category = item.categoryPermalink + ? categoriesByPermalink.get(item.categoryPermalink) + : undefined; + const title = item.title ?? category?.name ?? null; + + if (!title) { + return null; + } + + const childCount = Array.isArray(category?.children) + ? category.children.length + : 0; + + return { + title, + description: buildCollectionDescription( + title, + childCount, + item.description, + ), + href: buildCategoryHref( + basePath, + item.categoryPermalink ?? category?.permalink ?? undefined, + item.href, + ), + } satisfies CollectionCard; + }) + .filter((card): card is CollectionCard => card !== null); +} + +export async function FeaturedCollectionsSection({ + basePath, + section, +}: FeaturedCollectionsSectionProps) { + const theme = section.theme ?? {}; + const sectionStyle: CSSProperties = { + backgroundColor: theme.background, + color: theme.foreground, + }; + const mutedTextColor = theme.mutedForeground ?? "#64748b"; + const cardBackground = theme.cardBackground ?? "rgba(255, 255, 255, 0.78)"; + const borderColor = theme.borderColor ?? "#cbd5e1"; + const categories = await getCategories({ depth_eq: 0, expand: ["children"] }) + .then((response) => response.data ?? []) + .catch(() => []); + + const fallbackCards = categories.map((category) => { + const title = category.name; + const childCount = Array.isArray(category.children) + ? category.children.length + : 0; + + return { + title, + description: buildCollectionDescription(title, childCount), + href: `${basePath}/c/${category.permalink}`, + } satisfies CollectionCard; + }); + + const cards = ( + section.items?.length + ? buildRequestedCards(basePath, section.items, categories) + : fallbackCards + ).slice(0, section.maxItems ?? 6); + + return ( +
+
+
+
+ {section.eyebrow ? ( + + {section.eyebrow} + + ) : null} +

+ {section.title} +

+ {section.description ? ( +

+ {section.description} +

+ ) : null} +
+ {section.cta ? ( + + ) : null} +
+ {cards.length > 0 ? ( +
+ {cards.map((card) => ( + +
+ + Collection + +

+ {card.title} +

+

+ {card.description} +

+
+ + Explore collection + + + + ))} +
+ ) : ( +
+

+ Collections will appear here soon. +

+

+ Add root categories in Spree or provide curated collection items + in the section config. +

+
+ )} +
+
+ ); +} diff --git a/src/components/page-builder/PageSectionsRenderer.tsx b/src/components/page-builder/PageSectionsRenderer.tsx index bb316d1e..001b49fc 100644 --- a/src/components/page-builder/PageSectionsRenderer.tsx +++ b/src/components/page-builder/PageSectionsRenderer.tsx @@ -1,8 +1,11 @@ import { FeaturedProductsSection } from "@/components/home/FeaturedProductsSection"; import { FeaturesSection } from "@/components/home/FeaturesSection"; import { HeroSection } from "@/components/home/HeroSection"; +import { FaqSection } from "@/components/page-builder/FaqSection"; +import { FeaturedCollectionsSection } from "@/components/page-builder/FeaturedCollectionsSection"; import { ImageBannerSection } from "@/components/page-builder/ImageBannerSection"; import { RichTextSection } from "@/components/page-builder/RichTextSection"; +import { TestimonialsSection } from "@/components/page-builder/TestimonialsSection"; import type { DynamicPageSectionConfig } from "@/lib/page-builder"; interface PageSectionsRendererProps { @@ -45,6 +48,18 @@ export function PageSectionsRenderer({ section={section} /> ); + case "featured-collections": + return ( + + ); + case "faq": + return ; + case "testimonials": + return ; case "rich-text": return ( +
+
+
+ {section.eyebrow ? ( + + {section.eyebrow} + + ) : null} +

+ {section.title} +

+ {section.description ? ( +

+ {section.description} +

+ ) : null} +
+
+ {section.items.map((item, index) => { + const rating = getRating(item.rating); + + return ( +
+
+ {rating > 0 ? ( +
+ {`${rating} out of 5 stars`} + {Array.from({ length: rating }, (_, ratingIndex) => ( + + ))} +
+ ) : null} +
+ “{item.quote}” +
+
+
+

{item.author}

+ {item.role || item.company ? ( +

+ {[item.role, item.company].filter(Boolean).join(", ")} +

+ ) : null} +
+
+ ); + })} +
+
+
+ + ); +} diff --git a/src/components/page-builder/__tests__/FaqSection.test.tsx b/src/components/page-builder/__tests__/FaqSection.test.tsx new file mode 100644 index 00000000..5e501326 --- /dev/null +++ b/src/components/page-builder/__tests__/FaqSection.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { FaqSection } from "../FaqSection"; + +describe("FaqSection", () => { + it("renders section copy and FAQ items", () => { + render( + , + ); + + expect(screen.getByText("Questions before checkout")).toBeInTheDocument(); + expect( + screen.getByText("Helpful details that remove friction."), + ).toBeInTheDocument(); + expect(screen.getByText("How do I get started?")).toBeInTheDocument(); + expect( + screen.getByText("Browse the featured products and collections first."), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/page-builder/__tests__/FeaturedCollectionsSection.test.tsx b/src/components/page-builder/__tests__/FeaturedCollectionsSection.test.tsx new file mode 100644 index 00000000..d882afc8 --- /dev/null +++ b/src/components/page-builder/__tests__/FeaturedCollectionsSection.test.tsx @@ -0,0 +1,131 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { getCategories } from "@/lib/data/categories"; +import { FeaturedCollectionsSection } from "../FeaturedCollectionsSection"; + +vi.mock("@/lib/data/categories", () => ({ + getCategories: vi.fn(), +})); + +const mockGetCategories = vi.mocked(getCategories); + +describe("FeaturedCollectionsSection", () => { + it("renders root categories when no curated items are provided", async () => { + mockGetCategories.mockResolvedValue({ + data: [ + { + name: "Living Room", + permalink: "living-room", + children: [{ id: "1" }], + }, + { name: "Bedroom", permalink: "bedroom", children: [] }, + ], + } as never); + + const element = await FeaturedCollectionsSection({ + basePath: "/ke/en", + section: { + type: "featured-collections", + title: "Shop by room", + }, + }); + + render(element); + + expect(screen.getByText("Living Room")).toBeInTheDocument(); + expect(screen.getByText("Bedroom")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /living room/i })).toHaveAttribute( + "href", + "/ke/en/c/living-room", + ); + }); + + it("prefers curated items and preserves CTA links", async () => { + mockGetCategories.mockResolvedValue({ + data: [{ name: "Outdoor", permalink: "outdoor", children: [] }], + } as never); + + const element = await FeaturedCollectionsSection({ + basePath: "/ke/en", + section: { + type: "featured-collections", + title: "Explore collections", + cta: { + label: "See everything", + href: "/products", + }, + items: [ + { + title: "Outdoor living", + description: "Spaces designed for open-air comfort.", + href: "/collections/outdoor-living", + }, + ], + }, + }); + + render(element); + + expect(screen.getByText("Outdoor living")).toBeInTheDocument(); + expect( + screen.getByText("Spaces designed for open-air comfort."), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /see everything/i }), + ).toHaveAttribute("href", "/ke/en/products"); + expect( + screen.getByRole("link", { name: /outdoor living/i }), + ).toHaveAttribute("href", "/ke/en/collections/outdoor-living"); + }); + + it("builds curated cards from matched categories when only permalinks are provided", async () => { + mockGetCategories.mockResolvedValue({ + data: [ + { + name: "Outdoor", + permalink: "outdoor", + children: [{ id: "chairs" }, { id: "tables" }], + }, + ], + } as never); + + const element = await FeaturedCollectionsSection({ + basePath: "/ke/en", + section: { + type: "featured-collections", + title: "Browse categories", + items: [{ categoryPermalink: "outdoor" }], + }, + }); + + render(element); + + expect(screen.getByText("Outdoor")).toBeInTheDocument(); + expect( + screen.getByText("2 curated categories ready to explore."), + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /outdoor/i })).toHaveAttribute( + "href", + "/ke/en/c/outdoor", + ); + }); + + it("shows an empty state when no valid collection cards can be resolved", async () => { + mockGetCategories.mockRejectedValue(new Error("categories unavailable")); + + const element = await FeaturedCollectionsSection({ + basePath: "/ke/en", + section: { + type: "featured-collections", + title: "Browse categories", + items: [{}], + }, + }); + + render(element); + + expect( + screen.getByText("Collections will appear here soon."), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/page-builder/__tests__/PageSectionsRenderer.test.tsx b/src/components/page-builder/__tests__/PageSectionsRenderer.test.tsx new file mode 100644 index 00000000..de2249d1 --- /dev/null +++ b/src/components/page-builder/__tests__/PageSectionsRenderer.test.tsx @@ -0,0 +1,108 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { PageSectionsRenderer } from "../PageSectionsRenderer"; + +vi.mock("@/components/home/FeaturedProductsSection", () => ({ + FeaturedProductsSection: ({ section }: { section: { title: string } }) => ( +
{section.title}
+ ), +})); + +vi.mock("@/components/home/FeaturesSection", () => ({ + FeaturesSection: ({ section }: { section: { title?: string } }) => ( +
{section.title ?? "features"}
+ ), +})); + +vi.mock("@/components/home/HeroSection", () => ({ + HeroSection: ({ section }: { section: { title: string } }) => ( +
{section.title}
+ ), +})); + +vi.mock("@/components/page-builder/FeaturedCollectionsSection", () => ({ + FeaturedCollectionsSection: ({ section }: { section: { title: string } }) => ( +
{section.title}
+ ), +})); + +vi.mock("@/components/page-builder/FaqSection", () => ({ + FaqSection: ({ section }: { section: { title: string } }) => ( +
{section.title}
+ ), +})); + +vi.mock("@/components/page-builder/TestimonialsSection", () => ({ + TestimonialsSection: ({ section }: { section: { title: string } }) => ( +
{section.title}
+ ), +})); + +vi.mock("@/components/page-builder/RichTextSection", () => ({ + RichTextSection: ({ section }: { section: { title?: string } }) => ( +
{section.title ?? "rich-text"}
+ ), +})); + +vi.mock("@/components/page-builder/ImageBannerSection", () => ({ + ImageBannerSection: ({ section }: { section: { title: string } }) => ( +
{section.title}
+ ), +})); + +describe("PageSectionsRenderer", () => { + it("renders every supported storefront section type", () => { + render( + , + ); + + expect(screen.getByTestId("hero")).toHaveTextContent("Hero"); + expect(screen.getByTestId("features")).toHaveTextContent("Highlights"); + expect(screen.getByTestId("featured-products")).toHaveTextContent( + "Featured", + ); + expect(screen.getByTestId("featured-collections")).toHaveTextContent( + "Collections", + ); + expect(screen.getByTestId("faq")).toHaveTextContent("FAQ"); + expect(screen.getByTestId("testimonials")).toHaveTextContent( + "Loved by shoppers", + ); + expect(screen.getByTestId("rich-text")).toHaveTextContent("Story"); + expect(screen.getByTestId("image-banner")).toHaveTextContent("Banner"); + }); + + it("ignores unsupported section types safely", () => { + render( + , + ); + + expect(screen.queryByTestId("hero")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/page-builder/__tests__/TestimonialsSection.test.tsx b/src/components/page-builder/__tests__/TestimonialsSection.test.tsx new file mode 100644 index 00000000..3422a332 --- /dev/null +++ b/src/components/page-builder/__tests__/TestimonialsSection.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { TestimonialsSection } from "../TestimonialsSection"; + +describe("TestimonialsSection", () => { + it("renders testimonial cards with optional ratings and meta", () => { + render( + , + ); + + expect(screen.getByText("Trusted by regulars")).toBeInTheDocument(); + expect( + screen.getByText(/The fastest checkout and the easiest reorder flow\./), + ).toBeInTheDocument(); + expect(screen.getByText("Jamie")).toBeInTheDocument(); + expect(screen.getByText("Operations Lead, Oak & Co")).toBeInTheDocument(); + expect(screen.getByText("5 out of 5 stars")).toBeInTheDocument(); + expect(screen.getByText("Morgan")).toBeInTheDocument(); + }); +}); diff --git a/src/lib/homepage/index.test.ts b/src/lib/homepage/index.test.ts new file mode 100644 index 00000000..183a90b0 --- /dev/null +++ b/src/lib/homepage/index.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "vitest"; + +import { + getDefaultHomepageConfig, + getHomepageConfig, + getHomepageSections, +} from "./index"; + +describe("homepage config", () => { + it("builds homepage sections from tenant layout pages", () => { + const config = getHomepageConfig( + { storeName: "Jishop" }, + { + design: { + layout: { + pages: [ + { + slug: "index", + title: "Home", + summary: + "Kenya's premier destination for curated formal and casual wear.", + sections: ["hero", "highlights", "trust", "cta"], + is_homepage: true, + }, + ], + }, + }, + }, + ); + + expect(config.sections.map((section) => section.type)).toEqual([ + "hero", + "features", + "features", + "featured-products", + ]); + expect(config.sections[0]).toMatchObject({ + title: "Jishop curated for modern living", + description: + "Kenya's premier destination for curated formal and casual wear.", + }); + }); + + it("prefers layout.homepage over non-home content pages", () => { + const config = getHomepageConfig( + { storeName: "Jishop" }, + { + design: { + layout: { + homepage: { + version: 1, + sections: [ + { + type: "hero", + title: "Elevated Style for the Modern Kenyan", + }, + { + type: "features", + title: "The Jishop Standard", + items: [ + { + icon: "sparkles", + title: "Styled to stand out", + description: "Curated details for everyday wear.", + }, + ], + }, + { + type: "features", + title: "Why choose Jishop", + items: [ + { + icon: "truck", + title: "Fast Nationwide Shipping", + description: + "Receive your order within 1 to 3 business days.", + }, + ], + }, + { + type: "featured-products", + title: "Trending Now", + }, + ], + }, + pages: [ + { + slug: "shipping-exchange", + title: "Shipping & Exchange Policy", + summary: "Delivery times and exchange criteria.", + sections: [ + { + type: "hero", + title: "Our Commitment to You", + }, + { + type: "rich-text", + body: ["Shipping details"], + }, + ], + is_homepage: false, + }, + ], + }, + }, + }, + ); + + expect(config.sections.map((section) => section.type)).toEqual([ + "hero", + "features", + "features", + "featured-products", + ]); + expect(config.sections[0]).toMatchObject({ + title: "Elevated Style for the Modern Kenyan", + }); + expect(config.sections[1]).toMatchObject({ + title: "The Jishop Standard", + }); + expect(config.sections[2]).toMatchObject({ + title: "Why choose Jishop", + }); + expect(config.sections[3]).toMatchObject({ + title: "Trending Now", + }); + }); + + it("reads a direct source.homepage override", () => { + const config = getHomepageConfig( + { storeName: "Jishop" }, + { + homepage: { + version: 1, + sections: [ + { + type: "hero", + title: "Homepage from source.homepage", + }, + ], + }, + }, + ); + + expect(config.sections).toHaveLength(1); + expect(config.sections[0]).toMatchObject({ + type: "hero", + title: "Homepage from source.homepage", + }); + }); + + it("falls back to the first layout page when no homepage is marked", () => { + const config = getHomepageConfig( + { storeName: "Jishop" }, + { + design: { + layout: { + pages: [ + { + slug: "shipping-exchange", + title: "Shipping & Exchange Policy", + summary: "Delivery times and exchange criteria.", + sections: ["hero", "cta"], + }, + ], + }, + }, + }, + ); + + expect(config.sections.map((section) => section.type)).toEqual([ + "hero", + "featured-products", + ]); + expect(config.sections[0]).toMatchObject({ + title: "Shipping & Exchange Policy", + description: "Delivery times and exchange criteria.", + }); + }); + + it("returns the default homepage config for unrelated payloads", () => { + const config = getHomepageConfig({ storeName: "Jishop" }, { foo: "bar" }); + + expect(config.sections.map((section) => section.type)).toEqual([ + "hero", + "features", + "featured-products", + ]); + }); + + it("interpolates the default homepage config and exposes sections", () => { + const config = getDefaultHomepageConfig({ storeName: "Jishop" }); + + expect(config.sections[0]).toMatchObject({ + title: "Jishop curated for modern living", + }); + expect(getHomepageSections(config)).toEqual(config.sections); + }); + + it("falls back to defaults for malformed homepage payloads", () => { + const malformedLayoutConfig = getHomepageConfig( + { storeName: "Jishop" }, + { + design: { + layout: { + homepage: { theme: { accent: "#000000" } }, + }, + }, + }, + ); + const invalidSectionsConfig = getHomepageConfig( + { storeName: "Jishop" }, + { + version: 1, + sections: [{}], + }, + ); + + expect( + malformedLayoutConfig.sections.map((section) => section.type), + ).toEqual(["hero", "features", "featured-products"]); + expect( + invalidSectionsConfig.sections.map((section) => section.type), + ).toEqual(["hero", "features", "featured-products"]); + }); +}); diff --git a/src/lib/homepage/index.ts b/src/lib/homepage/index.ts index 0a8da02f..10d4ff02 100644 --- a/src/lib/homepage/index.ts +++ b/src/lib/homepage/index.ts @@ -12,8 +12,16 @@ function isHomepageSection(value: unknown): value is HomepageSectionConfig { function isHomepageConfigLike( value: unknown, ): value is Partial { + if (!isRecord(value)) { + return false; + } + + const hasHomepageKeys = "version" in value || "sections" in value; + if (!hasHomepageKeys) { + return false; + } + return ( - isRecord(value) && (value.version === undefined || value.version === 1) && (value.sections === undefined || Array.isArray(value.sections)) ); @@ -75,6 +83,104 @@ function mergeObjects(base: T, override: unknown): T { return result as T; } +function getRecord(value: unknown): Record | undefined { + return isRecord(value) ? value : undefined; +} + +function getString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function getArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function findHomepagePage(source: Record): unknown { + const design = getRecord(source.design); + const layout = getRecord(design?.layout); + const pages = getArray(layout?.pages); + + if (pages.length === 0) { + return undefined; + } + + const homepage = pages.find((page) => { + if (!isRecord(page)) return false; + + const slug = getString(page.slug)?.toLowerCase(); + return ( + page.is_homepage === true || + slug === "index" || + slug === "home" || + slug === "homepage" + ); + }); + + return homepage; +} + +function cloneSection(section: T): T { + return mergeObjects(section, {}); +} + +function buildHomepageSectionsFromSlugs( + slugs: unknown[], + defaultConfig: HomepageConfig, + source: Record, +): HomepageSectionConfig[] { + const heroSection = defaultConfig.sections.find( + (section) => section.type === "hero", + ); + const featuresSection = defaultConfig.sections.find( + (section) => section.type === "features", + ); + const featuredProductsSection = defaultConfig.sections.find( + (section) => section.type === "featured-products", + ); + + const pageTitle = getString(source.title); + const pageSummary = getString(source.summary); + const heroTitle = + pageTitle && + !["home", "homepage", "index"].includes(pageTitle.toLowerCase()) + ? pageTitle + : heroSection?.title; + + return slugs + .map((slug) => getString(slug)?.toLowerCase()) + .filter((slug): slug is string => Boolean(slug)) + .map((slug) => { + switch (slug) { + case "hero": + return heroSection + ? { + ...cloneSection(heroSection), + title: heroTitle ?? heroSection.title, + description: pageSummary || heroSection.description, + } + : null; + case "highlights": + case "features": + return featuresSection ? cloneSection(featuresSection) : null; + case "trust": + return featuresSection + ? { + ...cloneSection(featuresSection), + title: "Trust at every step", + } + : null; + case "cta": + case "featured-products": + return featuredProductsSection + ? cloneSection(featuredProductsSection) + : null; + default: + return null; + } + }) + .filter((section): section is HomepageSectionConfig => Boolean(section)); +} + function mergeHomepageSections( baseSections: HomepageSectionConfig[], overrideSections: unknown, @@ -123,7 +229,22 @@ function extractHomepageSource(source: unknown): unknown { return source; } - return layout.homepage; + const layoutHomepage = layout.homepage; + if (isHomepageConfigLike(layoutHomepage)) { + return layoutHomepage; + } + + const pages = layout.pages; + if (Array.isArray(pages)) { + const homepagePage = findHomepagePage(source as Record); + if (isRecord(homepagePage)) { + return homepagePage; + } + + return pages[0]; + } + + return layoutHomepage; } export function getDefaultHomepageConfig( @@ -137,6 +258,38 @@ export function getHomepageConfig( source?: unknown, ): HomepageConfig { const defaultConfig = getDefaultHomepageConfig(variables); + + if (isRecord(source)) { + const homepagePage = findHomepagePage(source); + if (isRecord(homepagePage) && Array.isArray(homepagePage.sections)) { + const interpolatedPage = interpolateValue(homepagePage, variables); + const pageSource = isRecord(interpolatedPage) + ? interpolatedPage + : undefined; + + if (pageSource) { + const pageSections = getArray(pageSource.sections); + const structuredSections = pageSections.filter(isHomepageSection); + const homepageSections = + structuredSections.length > 0 + ? mergeHomepageSections(defaultConfig.sections, structuredSections) + : buildHomepageSectionsFromSlugs( + pageSections, + defaultConfig, + pageSource, + ); + + if (homepageSections.length > 0) { + return { + ...mergeObjects(defaultConfig, pageSource), + sections: homepageSections, + version: 1, + }; + } + } + } + } + const extractedSource = extractHomepageSource(source); if (!isHomepageConfigLike(extractedSource)) { @@ -144,6 +297,32 @@ export function getHomepageConfig( } const overrideConfig = interpolateValue(extractedSource, variables); + const pageSource = isRecord(overrideConfig) ? overrideConfig : undefined; + + if (pageSource && Array.isArray(pageSource.sections)) { + const sections = pageSource.sections.filter(isHomepageSection); + if (sections.length > 0) { + return { + ...mergeObjects(defaultConfig, overrideConfig), + sections: mergeHomepageSections(defaultConfig.sections, sections), + version: 1, + }; + } + + const homepageSections = buildHomepageSectionsFromSlugs( + pageSource.sections, + defaultConfig, + pageSource, + ); + + if (homepageSections.length > 0) { + return { + ...mergeObjects(defaultConfig, overrideConfig), + sections: homepageSections, + version: 1, + }; + } + } return { ...mergeObjects(defaultConfig, overrideConfig), diff --git a/src/lib/homepage/types.ts b/src/lib/homepage/types.ts index e6d2d1dc..88c34f5b 100644 --- a/src/lib/homepage/types.ts +++ b/src/lib/homepage/types.ts @@ -72,10 +72,62 @@ export interface HomepageFeaturedProductsSectionConfig { theme?: HomepageThemeConfig; } +export interface HomepageCollectionItemConfig { + title?: string; + description?: string; + href?: string; + categoryPermalink?: string; +} + +export interface HomepageFeaturedCollectionsSectionConfig { + type: "featured-collections"; + eyebrow?: string; + title: string; + description?: string; + items?: HomepageCollectionItemConfig[]; + maxItems?: number; + cta?: HomepageButtonConfig; + theme?: HomepageThemeConfig; +} + +export interface HomepageFaqItemConfig { + question: string; + answer: string; +} + +export interface HomepageFaqSectionConfig { + type: "faq"; + eyebrow?: string; + title: string; + description?: string; + items: HomepageFaqItemConfig[]; + theme?: HomepageThemeConfig; +} + +export interface HomepageTestimonialItemConfig { + quote: string; + author: string; + role?: string; + company?: string; + rating?: 1 | 2 | 3 | 4 | 5; +} + +export interface HomepageTestimonialsSectionConfig { + type: "testimonials"; + eyebrow?: string; + title: string; + description?: string; + items: HomepageTestimonialItemConfig[]; + theme?: HomepageThemeConfig; +} + export type HomepageSectionConfig = | HomepageHeroSectionConfig | HomepageFeaturesSectionConfig - | HomepageFeaturedProductsSectionConfig; + | HomepageFeaturedProductsSectionConfig + | HomepageFeaturedCollectionsSectionConfig + | HomepageFaqSectionConfig + | HomepageTestimonialsSectionConfig; export interface HomepageConfig { version: 1; diff --git a/src/lib/page-builder/index.test.ts b/src/lib/page-builder/index.test.ts new file mode 100644 index 00000000..c070b157 --- /dev/null +++ b/src/lib/page-builder/index.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "vitest"; + +import { + getDynamicPagesConfig, + listDynamicPageSlugs, + resolveDynamicPage, +} from "./index"; + +describe("dynamic pages config", () => { + it("reads nested layout pages from tenant config", () => { + const config = getDynamicPagesConfig( + { storeName: "Jishop" }, + { + design: { + layout: { + pages: [ + { + slug: "shipping-exchange", + title: "Shipping & Exchange Policy", + description: + "Everything you need to know about delivery and exchanges.", + sections: [ + { + type: "hero", + title: "Our Commitment to You", + description: + "Transparent shipping and a fair exchange policy.", + }, + { + type: "rich-text", + title: "Shipping & Exchange Policy", + body: ["Shipping details", "Exchange details"], + }, + ], + is_homepage: false, + }, + ], + }, + }, + }, + ); + + const page = resolveDynamicPage(config, ["shipping-exchange"]); + + expect(page).toMatchObject({ + slug: "shipping-exchange", + title: "Shipping & Exchange Policy", + }); + expect(page?.sections.map((section) => section.type)).toEqual([ + "hero", + "rich-text", + ]); + }); + + it("supports direct and nested dynamic page payloads", () => { + const directConfig = getDynamicPagesConfig( + { storeName: "Jishop" }, + { + version: 1, + pages: [ + { + slug: "faq", + title: "FAQ", + sections: [ + { + type: "rich-text", + body: ["Answers to common questions"], + }, + ], + }, + ], + }, + ); + const nestedConfig = getDynamicPagesConfig( + { storeName: "Jishop" }, + { + dynamicPages: { + version: 1, + pages: [ + { + slug: "contact", + title: "Contact", + sections: [ + { + type: "hero", + title: "Talk to us", + description: "We are ready to help.", + }, + ], + }, + ], + }, + }, + ); + + expect(resolveDynamicPage(directConfig, "faq")?.title).toBe("FAQ"); + expect( + resolveDynamicPage(nestedConfig, ["contact"])?.sections, + ).toHaveLength(1); + }); + + it("merges root pages, exposes slugs, and falls back to defaults for invalid input", () => { + const config = getDynamicPagesConfig( + { storeName: "Jishop" }, + { + pages: [ + { + slug: "about/brand-story", + title: "About Jishop", + seo: { + title: "About Jishop Updated", + }, + sections: [ + { + type: "hero", + title: "Updated story", + description: "Our updated brand story.", + }, + ], + }, + { + slug: "policies/shipping", + title: "Shipping Policy", + sections: [ + { + type: "rich-text", + body: ["Shipping policy details"], + }, + ], + }, + ], + }, + ); + const defaultConfig = getDynamicPagesConfig( + { storeName: "Jishop" }, + { foo: "bar" }, + ); + + expect(resolveDynamicPage(config, "about/brand-story")).toMatchObject({ + title: "About Jishop", + seo: { + title: "About Jishop Updated", + }, + }); + expect(resolveDynamicPage(config, "policies/shipping")).toMatchObject({ + title: "Shipping Policy", + }); + expect(listDynamicPageSlugs(config)).toEqual( + expect.arrayContaining(["about/brand-story", "policies/shipping"]), + ); + expect(resolveDynamicPage(config, [])).toBeNull(); + expect(defaultConfig.pages[0]).toMatchObject({ + slug: "about/brand-story", + }); + }); + + it("falls back cleanly when pages are missing or layout payloads are malformed", () => { + const nullSourceConfig = getDynamicPagesConfig( + { storeName: "Jishop" }, + null, + ); + const noPagesConfig = getDynamicPagesConfig( + { storeName: "Jishop" }, + { + version: 1, + }, + ); + const malformedLayoutConfig = getDynamicPagesConfig( + { storeName: "Jishop" }, + { + design: { + layout: [], + }, + }, + ); + const layoutWithoutPagesConfig = getDynamicPagesConfig( + { storeName: "Jishop" }, + { + design: { + layout: { + navigation: [], + }, + }, + }, + ); + + expect(noPagesConfig.pages[0]).toMatchObject({ + slug: "about/brand-story", + }); + expect(nullSourceConfig.pages[0]).toMatchObject({ + slug: "about/brand-story", + }); + expect(malformedLayoutConfig.pages[0]).toMatchObject({ + slug: "about/brand-story", + }); + expect(layoutWithoutPagesConfig.pages[0]).toMatchObject({ + slug: "about/brand-story", + }); + }); +}); diff --git a/src/lib/page-builder/index.ts b/src/lib/page-builder/index.ts index e16acf61..39b3ea64 100644 --- a/src/lib/page-builder/index.ts +++ b/src/lib/page-builder/index.ts @@ -59,8 +59,16 @@ function isDynamicPagesConfig(value: unknown): value is DynamicPagesConfig { function isDynamicPagesConfigLike( value: unknown, ): value is Partial { + if (!isRecord(value)) { + return false; + } + + const hasDynamicPageKeys = "version" in value || "pages" in value; + if (!hasDynamicPageKeys) { + return false; + } + return ( - isRecord(value) && (value.version === undefined || value.version === 1) && (value.pages === undefined || Array.isArray(value.pages)) ); @@ -77,7 +85,7 @@ function normalizeSlug(value: string | string[]): string { } function mergeObjects(base: T, override: unknown): T { - if (!isRecord(base) || !isRecord(override)) { + if (!isRecord(base)) { return (override ?? base) as T; } @@ -115,10 +123,6 @@ function extractDynamicPagesSource(source: unknown): unknown { return source.dynamicPages; } - if (Array.isArray(source.pages)) { - return { version: 1, pages: source.pages }; - } - const design = source.design; if (!isRecord(design)) { return source; diff --git a/src/lib/page-builder/types.ts b/src/lib/page-builder/types.ts index fdcba573..d4d79e59 100644 --- a/src/lib/page-builder/types.ts +++ b/src/lib/page-builder/types.ts @@ -1,8 +1,11 @@ import type { HomepageButtonConfig, + HomepageFaqSectionConfig, + HomepageFeaturedCollectionsSectionConfig, HomepageFeaturedProductsSectionConfig, HomepageFeaturesSectionConfig, HomepageHeroSectionConfig, + HomepageTestimonialsSectionConfig, HomepageThemeConfig, } from "@/lib/homepage"; @@ -38,6 +41,9 @@ export type DynamicPageSectionConfig = | HomepageHeroSectionConfig | HomepageFeaturesSectionConfig | HomepageFeaturedProductsSectionConfig + | HomepageFeaturedCollectionsSectionConfig + | HomepageFaqSectionConfig + | HomepageTestimonialsSectionConfig | DynamicPageRichTextSectionConfig | DynamicPageImageBannerSectionConfig; diff --git a/src/lib/spree/config.ts b/src/lib/spree/config.ts index 17af4ffe..d6493fc1 100644 --- a/src/lib/spree/config.ts +++ b/src/lib/spree/config.ts @@ -90,6 +90,16 @@ function resolvePropertyPath(target: unknown, path: PropertyKey[]) { return { parent, current }; } +function formatOperationPath(path: PropertyKey[]): string { + return path + .map((key) => + typeof key === "symbol" + ? (key.description ?? key.toString()) + : String(key), + ) + .join("."); +} + function createClientProxy(path: PropertyKey[] = []): Client { const proxyTarget = (() => undefined) as unknown as Client; @@ -105,12 +115,34 @@ function createClientProxy(path: PropertyKey[] = []): Client { return (async () => { const client = await resolveClient(); const { parent, current } = resolvePropertyPath(client, path); + const operation = formatOperationPath(path); if (typeof current !== "function") { return current; } - return current.apply(parent, args); + console.info("Calling Spree API", { + operation, + args, + }); + + try { + const response = await current.apply(parent, args); + + console.info("Spree API response", { + operation, + response, + }); + + return response; + } catch (error) { + console.info("Spree API error", { + operation, + error, + }); + + throw error; + } })(); }, }) as Client; diff --git a/src/lib/tenant/__tests__/announcement.test.ts b/src/lib/tenant/__tests__/announcement.test.ts new file mode 100644 index 00000000..50413d42 --- /dev/null +++ b/src/lib/tenant/__tests__/announcement.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { resolveTenantAnnouncementBar } from "../announcement"; + +describe("tenant announcement bar", () => { + it("prefers layout-level announcement bar config", () => { + const config = { + raw: { + design: { + layout: { + announcementBar: { + message: "Free shipping on launch week orders", + href: "/shop", + label: "Browse now", + theme: { + background: "#111827", + foreground: "#f8fafc", + }, + }, + }, + }, + }, + } as never; + + expect(resolveTenantAnnouncementBar(config)).toEqual({ + message: "Free shipping on launch week orders", + href: "/shop", + linkLabel: "Browse now", + backgroundColor: "#111827", + foregroundColor: "#f8fafc", + }); + }); + + it("supports nested links, defaults, and disabled bars", () => { + expect( + resolveTenantAnnouncementBar({ + raw: { + announcementBar: { + message: "Weekend sale", + link: { href: "/products", label: "See all" }, + }, + }, + } as never), + ).toEqual({ + message: "Weekend sale", + href: "/products", + linkLabel: "See all", + backgroundColor: undefined, + foregroundColor: undefined, + }); + + expect( + resolveTenantAnnouncementBar(undefined, { + message: "Launch offer", + href: "/products", + linkLabel: "Shop now", + }), + ).toEqual({ + message: "Launch offer", + href: "/products", + linkLabel: "Shop now", + backgroundColor: undefined, + foregroundColor: undefined, + }); + + expect( + resolveTenantAnnouncementBar({ + raw: { + announcementBar: { + message: "Hidden promo", + enabled: false, + }, + }, + } as never), + ).toBeNull(); + }); +}); diff --git a/src/lib/tenant/__tests__/resolvers.test.ts b/src/lib/tenant/__tests__/resolvers.test.ts new file mode 100644 index 00000000..2f676351 --- /dev/null +++ b/src/lib/tenant/__tests__/resolvers.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { resolveTenantFooter, resolveTenantNavigation } from "../resolvers"; + +describe("tenant resolvers", () => { + it("prefers layout navigation arrays over the root navigation links", () => { + const config = { + storeName: "Jishop", + storeDescription: "Shop the latest trends", + theme: {}, + seo: {}, + navigation: { + links: [{ label: "Root Home", href: "/root" }], + }, + raw: { + design: { + layout: { + navigation: [ + { label: "Home", href: "/" }, + { label: "Shop All", href: "/shop" }, + ], + footer: { + copy: "© 2026 Jishop Kenya. All rights reserved.", + }, + }, + }, + }, + } as never; + + const navigation = resolveTenantNavigation(config); + const footer = resolveTenantFooter(config); + + expect(navigation.headerLinks).toEqual([ + { label: "Home", href: "/" }, + { label: "Shop All", href: "/shop" }, + ]); + expect(footer.description).toBe( + "© 2026 Jishop Kenya. All rights reserved.", + ); + }); +}); diff --git a/src/lib/tenant/announcement.ts b/src/lib/tenant/announcement.ts new file mode 100644 index 00000000..84136f74 --- /dev/null +++ b/src/lib/tenant/announcement.ts @@ -0,0 +1,88 @@ +import type { TenantSurfaceConfig } from "./types"; + +export interface TenantAnnouncementBarConfig { + message: string; + href?: string; + linkLabel: string; + backgroundColor?: string; + foregroundColor?: string; +} + +function getRecord(value: unknown): Record | undefined { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + + return undefined; +} + +function getString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function getBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function getRawConfig( + config?: TenantSurfaceConfig | null, +): Record | undefined { + return getRecord(config?.raw); +} + +function getLayoutConfig( + config?: TenantSurfaceConfig | null, +): Record | undefined { + return getRecord(getRecord(getRawConfig(config)?.design)?.layout); +} + +export function resolveTenantAnnouncementBar( + config?: TenantSurfaceConfig | null, + defaults: Partial = {}, +): TenantAnnouncementBarConfig | null { + const raw = getRawConfig(config); + const layout = getLayoutConfig(config); + const announcementBar = + getRecord(layout?.announcementBar) || + getRecord(layout?.announcement) || + getRecord(raw?.announcementBar) || + getRecord(raw?.announcement); + + const message = + getString(announcementBar?.message) || defaults.message || undefined; + const isVisible = + getBoolean(announcementBar?.isVisible) ?? + getBoolean(announcementBar?.enabled) ?? + true; + + if (!message || !isVisible) { + return null; + } + + const link = getRecord(announcementBar?.link); + const href = + getString(announcementBar?.href) || + getString(announcementBar?.url) || + getString(link?.href) || + defaults.href; + const linkLabel = + getString(announcementBar?.label) || + getString(link?.label) || + defaults.linkLabel || + "Learn more"; + const theme = getRecord(announcementBar?.theme); + + return { + message, + ...(href ? { href } : {}), + linkLabel, + backgroundColor: + getString(theme?.background) || + getString(theme?.backgroundColor) || + defaults.backgroundColor, + foregroundColor: + getString(theme?.foreground) || + getString(theme?.foregroundColor) || + defaults.foregroundColor, + }; +} diff --git a/src/lib/tenant/index.ts b/src/lib/tenant/index.ts index 17d4f105..b58815bc 100644 --- a/src/lib/tenant/index.ts +++ b/src/lib/tenant/index.ts @@ -1,3 +1,4 @@ +export * from "./announcement"; export * from "./normalize"; export * from "./olitt"; export * from "./resolvers"; diff --git a/src/lib/tenant/normalize.ts b/src/lib/tenant/normalize.ts index 1712bd2d..5e501cc4 100644 --- a/src/lib/tenant/normalize.ts +++ b/src/lib/tenant/normalize.ts @@ -154,6 +154,30 @@ function collectPaymentKeys( return result; } +function normalizeLocalDevSpreeApiUrl(apiUrl: string): string { + if (process.env.NODE_ENV !== "development") { + return apiUrl; + } + + const overrideUrl = process.env.SPREE_API_DEV_URL?.trim(); + if (overrideUrl) { + try { + return new URL(overrideUrl).origin; + } catch { + return overrideUrl; + } + } + + try { + const url = new URL(apiUrl); + url.protocol = "http:"; + url.port = "5000"; + return url.origin; + } catch { + return apiUrl; + } +} + function collectPublicPaymentKeys( config: TenantConfig, ): PublicTenantPaymentKeys { @@ -169,7 +193,9 @@ function getSpreeConfig(record: Record): { apiUrl: string; publishableKey: string; } { - const apiUrl = pickString(record, ["spreeApiUrl"]) ?? ""; + const apiUrl = normalizeLocalDevSpreeApiUrl( + pickString(record, ["spreeApiUrl"]) ?? "", + ); const publishableKey = pickString(record, ["spreePublishableKey"]) ?? ""; return { apiUrl, publishableKey }; diff --git a/src/lib/tenant/resolvers.ts b/src/lib/tenant/resolvers.ts index 7a50e24c..8e980735 100644 --- a/src/lib/tenant/resolvers.ts +++ b/src/lib/tenant/resolvers.ts @@ -40,7 +40,9 @@ function getLink(value: unknown): TenantLink | null { } function getLinks(value: unknown): TenantLink[] { - return getArray(value) + const entries = getRecord(value)?.links ?? value; + + return getArray(entries) .map((entry) => getLink(entry)) .filter((entry): entry is TenantLink => Boolean(entry)); } @@ -155,32 +157,32 @@ export function resolveTenantNavigation( defaults: Partial = {}, ): TenantNavigationConfig { const raw = getRawConfig(config); - const layoutNavigation = getRecord(getLayoutConfig(config)?.navigation); + const layoutNavigation = getLayoutConfig(config)?.navigation; const navigation = getRecord(raw?.navigation) ?? getRecord(config?.navigation); const genericLinks = getFirstNonEmptyLinks( - layoutNavigation?.links, + layoutNavigation, navigation?.links, getTenantNavigationLinks(config), ); const headerLinks = getFirstNonEmptyLinks( - layoutNavigation?.headerLinks, + layoutNavigation, navigation?.headerLinks, genericLinks, defaults.headerLinks, ); const footerLinks = getFirstNonEmptyLinks( - layoutNavigation?.footerLinks, + layoutNavigation, navigation?.footerLinks, genericLinks, defaults.footerLinks, ); const checkoutLinks = getFirstNonEmptyLinks( - layoutNavigation?.checkoutLinks, + layoutNavigation, navigation?.checkoutLinks, genericLinks, defaults.checkoutLinks, @@ -200,6 +202,7 @@ export function resolveTenantFooter( return { description: getString(layoutFooter?.description) || + getString(layoutFooter?.copy) || getString(footer?.description) || getTenantDescription(config) || defaults.description, diff --git a/src/lib/tenant/surface.ts b/src/lib/tenant/surface.ts index bfe0eed2..5d5c14fe 100644 --- a/src/lib/tenant/surface.ts +++ b/src/lib/tenant/surface.ts @@ -19,6 +19,22 @@ function getArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } +function getLinks(value: unknown): TenantLink[] { + const record = getRecord(value); + const entries = record ? getArray(record.links) : getArray(value); + + return entries + .map((entry) => { + const record = getRecord(entry); + if (!record) return null; + const label = getString(record.label); + const href = getString(record.href); + if (!label || !href) return null; + return { label, href }; + }) + .filter((value): value is TenantLink => Boolean(value)); +} + export function getTenantBrandName( config?: TenantSurfaceConfig | null, ): string | undefined { @@ -111,17 +127,5 @@ export function getTenantSocialLinks( export function getTenantNavigationLinks( config?: TenantSurfaceConfig | null, ): TenantLink[] { - const navigation = getRecord(config?.navigation); - const links = getArray(navigation?.links); - - return links - .map((entry) => { - const record = getRecord(entry); - if (!record) return null; - const label = getString(record.label); - const href = getString(record.href); - if (!label || !href) return null; - return { label, href }; - }) - .filter((value): value is TenantLink => Boolean(value)); + return getLinks(config?.navigation); } From 34bedce03beaad3a7696029a146c7d0b755e5ebf Mon Sep 17 00:00:00 2001 From: kipsang Date: Thu, 21 May 2026 16:34:35 +0300 Subject: [PATCH 2/4] ci: update branches for push and pull_request triggers This change modifies the CI configuration to include the 'dev' branch in both push and pull_request events, allowing for better integration and testing workflows across multiple branches. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4a1c036..1ab20cb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, dev] pull_request: - branches: [main] + branches: [main, dev] concurrency: group: ${{ github.workflow }}-${{ github.ref }} From 3668276844df11dc4859a3c0e6aea6516e9b33e1 Mon Sep 17 00:00:00 2001 From: kipsang Date: Thu, 21 May 2026 16:40:52 +0300 Subject: [PATCH 3/4] fix(test): correct loading text in locale loading test Updated the expected text in the loading component test to match the correct branding. This ensures that the test accurately reflects the current application state and branding. --- src/app/[country]/[locale]/loading.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/[country]/[locale]/loading.test.tsx b/src/app/[country]/[locale]/loading.test.tsx index d6a66bfb..61e131d6 100644 --- a/src/app/[country]/[locale]/loading.test.tsx +++ b/src/app/[country]/[locale]/loading.test.tsx @@ -7,6 +7,6 @@ describe("Locale loading state", () => { it("renders a static fallback without tenant lookup", () => { render(); - expect(screen.getByText("Loading storefront")).toBeInTheDocument(); + expect(screen.getByText("Loading Store")).toBeInTheDocument(); }); }); From cbddc532071f49e5d2833e4771b5ef95feae7129 Mon Sep 17 00:00:00 2001 From: kipsang Date: Mon, 25 May 2026 14:56:56 +0300 Subject: [PATCH 4/4] fix(homepage): improve null checks in homepage section handling Added a new utility function `isDefined` to check for non-null values. Updated the `buildHomepageSectionsFromSlugs` function to use this utility for filtering sections, ensuring better handling of null values in the homepage configuration. --- src/lib/homepage/index.ts | 10 ++++++++-- src/lib/page-builder/index.ts | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/lib/homepage/index.ts b/src/lib/homepage/index.ts index 10d4ff02..bfa0885f 100644 --- a/src/lib/homepage/index.ts +++ b/src/lib/homepage/index.ts @@ -9,6 +9,10 @@ function isHomepageSection(value: unknown): value is HomepageSectionConfig { return isRecord(value) && typeof value.type === "string"; } +function isDefined(value: T | null): value is T { + return value !== null; +} + function isHomepageConfigLike( value: unknown, ): value is Partial { @@ -146,7 +150,7 @@ function buildHomepageSectionsFromSlugs( ? pageTitle : heroSection?.title; - return slugs + const sections = slugs .map((slug) => getString(slug)?.toLowerCase()) .filter((slug): slug is string => Boolean(slug)) .map((slug) => { @@ -178,7 +182,9 @@ function buildHomepageSectionsFromSlugs( return null; } }) - .filter((section): section is HomepageSectionConfig => Boolean(section)); + .filter(isDefined); + + return sections; } function mergeHomepageSections( diff --git a/src/lib/page-builder/index.ts b/src/lib/page-builder/index.ts index 39b3ea64..929ec9d4 100644 --- a/src/lib/page-builder/index.ts +++ b/src/lib/page-builder/index.ts @@ -85,7 +85,7 @@ function normalizeSlug(value: string | string[]): string { } function mergeObjects(base: T, override: unknown): T { - if (!isRecord(base)) { + if (!isRecord(base) || !isRecord(override)) { return (override ?? base) as T; }