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 }} 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..61e131d6 --- /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 Store")).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..bfa0885f 100644 --- a/src/lib/homepage/index.ts +++ b/src/lib/homepage/index.ts @@ -9,11 +9,23 @@ 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 { + 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 +87,106 @@ 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; + + const sections = 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(isDefined); + + return sections; +} + function mergeHomepageSections( baseSections: HomepageSectionConfig[], overrideSections: unknown, @@ -123,7 +235,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 +264,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 +303,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..929ec9d4 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)) ); @@ -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); }