Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
69 changes: 69 additions & 0 deletions src/app/[country]/[locale]/(storefront)/[...slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 (
<DynamicPageRenderer
page={page}
basePath={basePath}
locale={locale}
country={country}
currency={currency}
/>
);
}
147 changes: 147 additions & 0 deletions src/app/[country]/[locale]/(storefront)/__tests__/layout.test.tsx
Original file line number Diff line number Diff line change
@@ -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 };
}) => <div data-testid="announcement-bar">{announcementBar.message}</div>,
}));

vi.mock("@/components/layout/Header", () => ({
Header: () => <div data-testid="header">header</div>,
}));

vi.mock("@/components/layout/Footer", () => ({
Footer: () => <div data-testid="footer">footer</div>,
}));

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: <div>Page body</div>,
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: <div>Category page</div>,
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: <div>Fallback page</div>,
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();
});
});
141 changes: 141 additions & 0 deletions src/app/[country]/[locale]/(storefront)/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }>;
}) => (
<div data-testid="page-sections-renderer">
{sections.map((section, index) => (
<span key={`${section.type}-${index}`}>
{section.title ?? section.type}
</span>
))}
</div>
),
}));

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",
});
});
});
Loading
Loading