- © {new Date().getFullYear()} {storeName}. {t("poweredBy")}{" "}
+ © {new Date().getFullYear()} {branding.name}. {t("poweredBy")}
@@ -35,20 +37,25 @@ const LazyCountrySwitcher = dynamic(
},
);
-const storeName = getStoreName();
-
interface HeaderProps {
rootCategories: Category[];
basePath: string;
locale: Locale;
+ tenantConfig?: TenantConfig | null;
}
export async function Header({
rootCategories,
basePath,
locale,
+ tenantConfig,
}: HeaderProps) {
const t = await getTranslations({ locale, namespace: "header" });
+ const branding = resolveTenantBranding(tenantConfig, {
+ name: getStoreName(),
+ logoUrl: "/spree.png",
+ });
+ const navigation = resolveTenantNavigation(tenantConfig);
return (
}
rightStart={
-
-
+
+ {navigation.headerLinks.length > 0 && (
+
+ {navigation.headerLinks.map((link) => (
+
+ {link.label}
+
+ ))}
+
+ )}
+
+
+
}
rightEnd={
diff --git a/src/components/marketing/OlittFallbackPage.tsx b/src/components/marketing/OlittFallbackPage.tsx
new file mode 100644
index 00000000..e37a27d5
--- /dev/null
+++ b/src/components/marketing/OlittFallbackPage.tsx
@@ -0,0 +1,222 @@
+import Link from "next/link";
+
+const olittPrimaryCta = "https://olitt.com";
+const olittDomainsCta = "https://olitt.com";
+
+interface OlittFallbackPageProps {
+ host: string;
+}
+
+const offerCards = [
+ {
+ title: "AI websites that sell",
+ description:
+ "Launch a polished business website in less time with AI-assisted design, copy, structure, and brand-ready sections.",
+ },
+ {
+ title: "E-commerce built for growth",
+ description:
+ "Turn traffic into revenue with beautiful storefronts, product-first layouts, payments, and conversion-focused experiences.",
+ },
+ {
+ title: "WordPress, reimagined",
+ description:
+ "Get flexible WordPress sites with modern design direction, faster publishing, and less setup friction for your team.",
+ },
+ {
+ title: "Domains in one place",
+ description:
+ "Secure your brand with the right domain and manage the path from idea to live site from a single platform.",
+ },
+];
+
+const proofPoints = [
+ "Sell products with modern storefront experiences",
+ "Build service websites with AI-guided structure and content",
+ "Launch WordPress sites faster with less manual setup",
+ "Find and buy domains that fit your brand",
+];
+
+export function OlittFallbackPage({ host }: OlittFallbackPageProps) {
+ return (
+
+
+
+
+
+
+ This domain is connected — now let’s turn it into a business.
+
+
+ Build your next website, store, or domain-powered brand with
+ Olitt
+
+
+ We noticed{" "}
+ {host} points
+ here, but your storefront has not been launched yet. Olitt helps
+ you create high-converting websites, e-commerce stores, and
+ WordPress sites with AI — plus get the right domain to power it
+ all.
+
+
+
+ Start building with Olitt
+
+
+ Get a domain on Olitt
+
+
+
+
+
+
+
+
+
+ Olitt Growth Stack
+
+
+ Launch faster. Sell smarter.
+
+
+
+ AI powered
+
+
+
+ {proofPoints.map((point) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ Everything you need to launch online
+
+
+ One platform for websites, e-commerce, WordPress, and domains
+
+
+ Whether you’re starting with an idea, migrating a business, or
+ finally turning your domain into a real sales machine, Olitt gives
+ you the tools and AI support to get live faster.
+
+
+
+
+ {offerCards.map((card) => (
+
+
+ Olitt
+
+ {card.title}
+
+ {card.description}
+
+
+ ))}
+
+
+
+
+
+
+
+ Why businesses choose Olitt
+
+
+ More than a site builder — a launch partner for modern brands
+
+
+ We help brands move from “domain pointed” to “business live.” Use
+ AI to generate structure, messaging, and momentum — then publish a
+ site that looks serious, sells confidently, and grows with you.
+
+
+
+
+
+
24/7
+
+ AI-assisted creation flow
+
+
+
+
+
4 in 1
+
+ Websites, e-commerce, WordPress, and domains
+
+
+
+
+
1 goal
+
+ Get your business online and converting
+
+
+
+
+
+
+
+
+
+
+ Ready to launch?
+
+
+ Let Olitt turn this connected domain into your next revenue engine.
+
+
+ Build a website. Launch an online store. Start a WordPress project.
+ Buy the perfect domain. Do it all on Olitt with AI in your corner.
+
+
+
+ Visit Olitt.com
+
+
+ Explore domains and services
+
+
+
+
+
+ );
+}
diff --git a/src/components/page-builder/DynamicPageRenderer.tsx b/src/components/page-builder/DynamicPageRenderer.tsx
new file mode 100644
index 00000000..acc61120
--- /dev/null
+++ b/src/components/page-builder/DynamicPageRenderer.tsx
@@ -0,0 +1,29 @@
+import { PageSectionsRenderer } from "@/components/page-builder/PageSectionsRenderer";
+import type { DynamicPageConfig } from "@/lib/page-builder";
+
+interface DynamicPageRendererProps {
+ page: DynamicPageConfig;
+ basePath: string;
+ locale: string;
+ country: string;
+ currency?: string;
+}
+
+export function DynamicPageRenderer({
+ page,
+ basePath,
+ locale,
+ country,
+ currency,
+}: DynamicPageRendererProps) {
+ return (
+
+ );
+}
diff --git a/src/components/page-builder/ImageBannerSection.tsx b/src/components/page-builder/ImageBannerSection.tsx
new file mode 100644
index 00000000..0235761e
--- /dev/null
+++ b/src/components/page-builder/ImageBannerSection.tsx
@@ -0,0 +1,97 @@
+import { ArrowRight } from "lucide-react";
+import Link from "next/link";
+import type { CSSProperties, ReactElement } from "react";
+import { Button } from "@/components/ui/button";
+import type { DynamicPageImageBannerSectionConfig } from "@/lib/page-builder";
+
+interface ImageBannerSectionProps {
+ basePath: string;
+ section: DynamicPageImageBannerSectionConfig;
+}
+
+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 getHeightClass(
+ height: DynamicPageImageBannerSectionConfig["height"],
+): string {
+ switch (height) {
+ case "sm":
+ return "min-h-[320px]";
+ case "lg":
+ return "min-h-[560px]";
+ default:
+ return "min-h-[440px]";
+ }
+}
+
+export function ImageBannerSection({
+ basePath,
+ section,
+}: ImageBannerSectionProps): ReactElement {
+ const theme = section.theme ?? {};
+ const foreground = theme.foreground ?? "#ffffff";
+ const mutedTextColor = theme.mutedForeground ?? "#e5e7eb";
+ const overlayStyle: CSSProperties = section.overlay
+ ? {
+ background:
+ "linear-gradient(180deg, rgba(15, 23, 42, 0.25), rgba(15, 23, 42, 0.7))",
+ }
+ : {};
+
+ return (
+
+
+
+
+
+
+
+ {section.eyebrow ? (
+
+ {section.eyebrow}
+
+ ) : null}
+
+ {section.title}
+
+ {section.description ? (
+
+ {section.description}
+
+ ) : null}
+ {section.cta ? (
+
+
+ {section.cta.label}
+
+
+
+ ) : null}
+
+
+
+
+
+ );
+}
diff --git a/src/components/page-builder/PageSectionsRenderer.tsx b/src/components/page-builder/PageSectionsRenderer.tsx
new file mode 100644
index 00000000..bb316d1e
--- /dev/null
+++ b/src/components/page-builder/PageSectionsRenderer.tsx
@@ -0,0 +1,70 @@
+import { FeaturedProductsSection } from "@/components/home/FeaturedProductsSection";
+import { FeaturesSection } from "@/components/home/FeaturesSection";
+import { HeroSection } from "@/components/home/HeroSection";
+import { ImageBannerSection } from "@/components/page-builder/ImageBannerSection";
+import { RichTextSection } from "@/components/page-builder/RichTextSection";
+import type { DynamicPageSectionConfig } from "@/lib/page-builder";
+
+interface PageSectionsRendererProps {
+ sections: DynamicPageSectionConfig[];
+ basePath: string;
+ locale: string;
+ country: string;
+ currency?: string;
+ keyPrefix?: string;
+}
+
+export function PageSectionsRenderer({
+ sections,
+ basePath,
+ locale,
+ country,
+ currency,
+ keyPrefix = "section",
+}: PageSectionsRendererProps) {
+ return (
+
+ {sections.map((section, index) => {
+ const key = `${keyPrefix}-${section.type}-${index}`;
+
+ switch (section.type) {
+ case "hero":
+ return (
+
+ );
+ case "features":
+ return ;
+ case "featured-products":
+ return (
+
+ );
+ case "rich-text":
+ return (
+
+ );
+ case "image-banner":
+ return (
+
+ );
+ default:
+ return null;
+ }
+ })}
+
+ );
+}
diff --git a/src/components/page-builder/RichTextSection.tsx b/src/components/page-builder/RichTextSection.tsx
new file mode 100644
index 00000000..0c70c68b
--- /dev/null
+++ b/src/components/page-builder/RichTextSection.tsx
@@ -0,0 +1,68 @@
+import Link from "next/link";
+import type { CSSProperties } from "react";
+import { Button } from "@/components/ui/button";
+import type { DynamicPageRichTextSectionConfig } from "@/lib/page-builder";
+
+interface RichTextSectionProps {
+ basePath: string;
+ section: DynamicPageRichTextSectionConfig;
+}
+
+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 RichTextSection({ basePath, section }: RichTextSectionProps) {
+ const theme = section.theme ?? {};
+ const sectionStyle: CSSProperties = {
+ backgroundColor: theme.background,
+ color: theme.foreground,
+ };
+ const mutedTextColor = theme.mutedForeground ?? "#6b7280";
+ const alignmentClass =
+ section.alignment === "center"
+ ? "items-center text-center mx-auto"
+ : "items-start text-left";
+
+ return (
+
+
+
+ {section.eyebrow ? (
+
+ {section.eyebrow}
+
+ ) : null}
+ {section.title ? (
+
+ {section.title}
+
+ ) : null}
+
+ {section.body.map((paragraph, index) => (
+
+ {paragraph}
+
+ ))}
+
+ {section.cta ? (
+
+
+
+ {section.cta.label}
+
+
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/contexts/CartContext.tsx b/src/contexts/CartContext.tsx
index f8817bd1..0b89a338 100644
--- a/src/contexts/CartContext.tsx
+++ b/src/contexts/CartContext.tsx
@@ -34,6 +34,28 @@ interface CartContextType {
const CartContext = createContext
(undefined);
+const EMPTY_CART_CONTEXT: CartContextType = {
+ cart: null,
+ loading: false,
+ updating: false,
+ itemCount: 0,
+ isOpen: false,
+ openCart: () => {},
+ closeCart: () => {},
+ addItem: async () => {},
+ updateItem: async () => {},
+ removeItem: async () => {},
+ refreshCart: async () => {},
+};
+
+export function CartProviderFallback({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
export function CartProvider({ children }: { children: ReactNode }) {
const [cart, setCart] = useState(null);
const [loading, setLoading] = useState(true);
diff --git a/src/contexts/TenantContext.tsx b/src/contexts/TenantContext.tsx
new file mode 100644
index 00000000..e1ad9b27
--- /dev/null
+++ b/src/contexts/TenantContext.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import {
+ createContext,
+ type ReactElement,
+ type ReactNode,
+ useContext,
+ useMemo,
+} from "react";
+import type { PublicTenantConfig } from "@/lib/tenant";
+
+const TenantContext = createContext(null);
+
+export function TenantConfigProvider({
+ children,
+ config,
+}: {
+ children: ReactNode;
+ config: PublicTenantConfig;
+}): ReactElement {
+ const value = useMemo(() => config, [config]);
+
+ return (
+ {children}
+ );
+}
+
+export function useTenantConfig(): PublicTenantConfig {
+ const context = useContext(TenantContext);
+ if (!context) {
+ throw new Error(
+ "useTenantConfig must be used within a TenantConfigProvider",
+ );
+ }
+ return context;
+}
+
+export function useTenantTheme(): PublicTenantConfig["theme"] {
+ return useTenantConfig().theme;
+}
+
+export function useTenantNavigation(): PublicTenantConfig["navigation"] {
+ return useTenantConfig().navigation;
+}
+
+export function useTenantPayments(): PublicTenantConfig["paymentKeys"] {
+ return useTenantConfig().paymentKeys;
+}
+
+export function useTenantSpree(): PublicTenantConfig["spree"] {
+ return useTenantConfig().spree;
+}
diff --git a/src/contexts/__tests__/TenantContext.test.tsx b/src/contexts/__tests__/TenantContext.test.tsx
new file mode 100644
index 00000000..05f6fcba
--- /dev/null
+++ b/src/contexts/__tests__/TenantContext.test.tsx
@@ -0,0 +1,54 @@
+import { renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { TenantConfigProvider, useTenantConfig } from "../TenantContext";
+
+const tenantConfig = {
+ tenantId: "tenant-1",
+ host: "shop.example.com",
+ storeName: "Shop",
+ storeDescription: "Shop description",
+ defaultCountry: "us",
+ defaultLocale: "en",
+ spree: {
+ apiUrl: "https://spree.example.com",
+ publishableKey: "pub-key",
+ },
+ theme: {
+ colors: {
+ primary: "#111111",
+ },
+ },
+ seo: {},
+ navigation: {
+ links: [{ label: "Products", href: "/products" }],
+ },
+ paymentKeys: {
+ stripePublishableKey: "pk_test_123",
+ },
+} as never;
+
+function wrapper({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+describe("TenantConfigProvider", () => {
+ it("returns tenant config inside the provider", () => {
+ const { result } = renderHook(() => useTenantConfig(), { wrapper });
+
+ expect(result.current).toMatchObject({
+ tenantId: "tenant-1",
+ host: "shop.example.com",
+ spree: {
+ apiUrl: "https://spree.example.com",
+ publishableKey: "pub-key",
+ },
+ paymentKeys: {
+ stripePublishableKey: "pk_test_123",
+ },
+ });
+ });
+});
diff --git a/src/lib/data/categories.ts b/src/lib/data/categories.ts
index 5accdedf..266fa629 100644
--- a/src/lib/data/categories.ts
+++ b/src/lib/data/categories.ts
@@ -1,33 +1,100 @@
"use server";
-import type { CategoryListParams, ProductListParams } from "@spree/sdk";
+import type {
+ Category,
+ CategoryListParams,
+ PaginatedResponse,
+ Product,
+ ProductListParams,
+} from "@spree/sdk";
import { cacheLife, cacheTag } from "next/cache";
-import { getAccessToken, getClient, getLocaleOptions } from "@/lib/spree";
+import {
+ getAccessToken,
+ getClientForConfig,
+ getLocaleOptions,
+ getSpreeCacheScope,
+ resolveSpreeConfig,
+} from "@/lib/spree";
+
+function hasValidSpreeConfig(
+ baseUrl?: string,
+ publishableKey?: string,
+): boolean {
+ return Boolean(
+ baseUrl?.trim() &&
+ publishableKey?.trim() &&
+ /^https?:\/\//i.test(baseUrl.trim()),
+ );
+}
+
+function createEmptyPaginatedResponse(params?: {
+ page?: number;
+ limit?: number;
+}): PaginatedResponse {
+ return {
+ data: [],
+ meta: {
+ count: 0,
+ pages: 0,
+ page: Number(params?.page ?? 1),
+ limit: Number(params?.limit ?? 0),
+ from: 0,
+ to: 0,
+ in: 0,
+ previous: null,
+ next: null,
+ },
+ } as unknown as PaginatedResponse;
+}
async function cachedListCategories(
params: CategoryListParams | undefined,
options: { locale?: string; country?: string },
+ baseUrl: string,
+ publishableKey: string,
+ _spreeScope: string,
) {
"use cache: remote";
cacheLife("hours");
cacheTag("categories");
- return getClient().categories.list(params, options);
+ if (!hasValidSpreeConfig(baseUrl, publishableKey)) {
+ return createEmptyPaginatedResponse(params);
+ }
+
+ return getClientForConfig({ baseUrl, publishableKey }).categories.list(
+ params,
+ options,
+ );
}
export async function getCategories(params?: CategoryListParams) {
const options = await getLocaleOptions();
- return cachedListCategories(params, options);
+ const spreeConfig = await resolveSpreeConfig();
+ return cachedListCategories(
+ params,
+ options,
+ spreeConfig.baseUrl,
+ spreeConfig.publishableKey,
+ getSpreeCacheScope(spreeConfig),
+ );
}
async function cachedGetCategory(
idOrPermalink: string,
params: { expand?: string[] } | undefined,
options: { locale?: string; country?: string },
+ baseUrl: string,
+ publishableKey: string,
+ _spreeScope: string,
) {
"use cache: remote";
cacheLife("tenMinutes");
cacheTag("category");
- return getClient().categories.get(idOrPermalink, params, options);
+ return getClientForConfig({ baseUrl, publishableKey }).categories.get(
+ idOrPermalink,
+ params,
+ options,
+ );
}
export async function getCategory(
@@ -35,7 +102,15 @@ export async function getCategory(
params?: { expand?: string[] },
) {
const options = await getLocaleOptions();
- return cachedGetCategory(idOrPermalink, params, options);
+ const spreeConfig = await resolveSpreeConfig();
+ return cachedGetCategory(
+ idOrPermalink,
+ params,
+ options,
+ spreeConfig.baseUrl,
+ spreeConfig.publishableKey,
+ getSpreeCacheScope(spreeConfig),
+ );
}
/**
@@ -48,14 +123,21 @@ async function cachedListCategoryProducts(
params: ProductListParams | undefined,
options: { locale?: string; country?: string },
_userToken?: string,
+ baseUrl?: string,
+ publishableKey?: string,
+ _spreeScope?: string,
) {
"use cache: remote";
cacheLife("tenMinutes");
cacheTag("products", `category-products:${categoryId}`);
- return getClient().products.list(
- { ...params, in_category: categoryId },
- options,
- );
+ if (!hasValidSpreeConfig(baseUrl, publishableKey)) {
+ return createEmptyPaginatedResponse(params);
+ }
+
+ return getClientForConfig({
+ baseUrl: baseUrl ?? "",
+ publishableKey: publishableKey ?? "",
+ }).products.list({ ...params, in_category: categoryId }, options);
}
export async function getCategoryProducts(
@@ -64,5 +146,14 @@ export async function getCategoryProducts(
) {
const options = await getLocaleOptions();
const userToken = await getAccessToken();
- return cachedListCategoryProducts(categoryId, params, options, userToken);
+ const spreeConfig = await resolveSpreeConfig();
+ return cachedListCategoryProducts(
+ categoryId,
+ params,
+ options,
+ userToken,
+ spreeConfig.baseUrl,
+ spreeConfig.publishableKey,
+ getSpreeCacheScope(spreeConfig),
+ );
}
diff --git a/src/lib/data/countries.ts b/src/lib/data/countries.ts
index 93225f8b..96cfb5e9 100644
--- a/src/lib/data/countries.ts
+++ b/src/lib/data/countries.ts
@@ -1,7 +1,13 @@
"use server";
import { cacheLife, cacheTag } from "next/cache";
-import { getClient, getLocaleOptions } from "@/lib/spree";
+import {
+ getClient,
+ getClientForConfig,
+ getLocaleOptions,
+ getSpreeCacheScope,
+ resolveSpreeConfig,
+} from "@/lib/spree";
export async function getCountries() {
const options = await getLocaleOptions();
@@ -11,14 +17,28 @@ export async function getCountries() {
async function cachedGetCountry(
iso: string,
options: { locale?: string; country?: string },
+ baseUrl: string,
+ publishableKey: string,
+ _spreeScope: string,
) {
"use cache: remote";
cacheLife("hours");
cacheTag("country", `country-${iso}`);
- return getClient().countries.get(iso, { expand: ["states"] }, options);
+ return getClientForConfig({ baseUrl, publishableKey }).countries.get(
+ iso,
+ { expand: ["states"] },
+ options,
+ );
}
export async function getCountry(iso: string) {
const options = await getLocaleOptions();
- return cachedGetCountry(iso, options);
+ const spreeConfig = await resolveSpreeConfig();
+ return cachedGetCountry(
+ iso,
+ options,
+ spreeConfig.baseUrl,
+ spreeConfig.publishableKey,
+ getSpreeCacheScope(spreeConfig),
+ );
}
diff --git a/src/lib/data/markets.ts b/src/lib/data/markets.ts
index edfb0b1c..3188b7eb 100644
--- a/src/lib/data/markets.ts
+++ b/src/lib/data/markets.ts
@@ -2,36 +2,58 @@
import type { Market } from "@spree/sdk";
import { cacheLife, cacheTag } from "next/cache";
-import { getClient, getLocaleOptions } from "@/lib/spree";
+import {
+ getClientForConfig,
+ getLocaleOptions,
+ getSpreeCacheScope,
+ resolveSpreeConfig,
+} from "@/lib/spree";
-async function cachedListMarkets(options: {
- locale?: string;
- country?: string;
-}) {
+async function cachedListMarkets(
+ options: {
+ locale?: string;
+ country?: string;
+ },
+ baseUrl: string,
+ publishableKey: string,
+ _spreeScope: string,
+) {
"use cache: remote";
cacheLife("hours");
cacheTag("markets");
- return getClient().markets.list(options);
+ return getClientForConfig({ baseUrl, publishableKey }).markets.list(options);
}
async function cachedResolveMarket(
country: string,
options: { locale?: string; country?: string },
+ baseUrl: string,
+ publishableKey: string,
+ _spreeScope: string,
) {
"use cache: remote";
cacheLife("hours");
cacheTag("resolved-market");
- return getClient().markets.resolve(country, options);
+ return getClientForConfig({ baseUrl, publishableKey }).markets.resolve(
+ country,
+ options,
+ );
}
async function cachedListMarketCountries(
marketId: string,
options: { locale?: string; country?: string },
+ baseUrl: string,
+ publishableKey: string,
+ _spreeScope: string,
) {
"use cache: remote";
cacheLife("hours");
cacheTag("market-countries");
- return getClient().markets.countries.list(marketId, options);
+ return getClientForConfig({ baseUrl, publishableKey }).markets.countries.list(
+ marketId,
+ options,
+ );
}
export async function getMarkets(options?: {
@@ -39,17 +61,38 @@ export async function getMarkets(options?: {
country?: string;
}): Promise<{ data: Market[] }> {
const resolvedOptions = options ?? (await getLocaleOptions());
- return cachedListMarkets(resolvedOptions);
+ const spreeConfig = await resolveSpreeConfig();
+ const spreeScope = getSpreeCacheScope(spreeConfig);
+ return cachedListMarkets(
+ resolvedOptions,
+ spreeConfig.baseUrl,
+ spreeConfig.publishableKey,
+ spreeScope,
+ );
}
export async function resolveMarket(country: string) {
const options = await getLocaleOptions();
- return cachedResolveMarket(country, options);
+ const spreeConfig = await resolveSpreeConfig();
+ return cachedResolveMarket(
+ country,
+ options,
+ spreeConfig.baseUrl,
+ spreeConfig.publishableKey,
+ getSpreeCacheScope(spreeConfig),
+ );
}
export async function getMarketCountries(marketId: string) {
const options = await getLocaleOptions();
- return cachedListMarketCountries(marketId, options);
+ const spreeConfig = await resolveSpreeConfig();
+ return cachedListMarketCountries(
+ marketId,
+ options,
+ spreeConfig.baseUrl,
+ spreeConfig.publishableKey,
+ getSpreeCacheScope(spreeConfig),
+ );
}
/**
diff --git a/src/lib/data/products.ts b/src/lib/data/products.ts
index bee79c3e..f57f12b1 100644
--- a/src/lib/data/products.ts
+++ b/src/lib/data/products.ts
@@ -1,8 +1,58 @@
"use server";
-import type { ProductListParams } from "@spree/sdk";
+import type {
+ PaginatedResponse,
+ Product,
+ ProductFiltersResponse,
+ ProductListParams,
+} from "@spree/sdk";
import { cacheLife, cacheTag } from "next/cache";
-import { getAccessToken, getClient, getLocaleOptions } from "@/lib/spree";
+import {
+ getAccessToken,
+ getClientForConfig,
+ getLocaleOptions,
+ getSpreeCacheScope,
+ resolveSpreeConfig,
+} from "@/lib/spree";
+
+function hasValidSpreeConfig(
+ baseUrl?: string,
+ publishableKey?: string,
+): boolean {
+ return Boolean(
+ baseUrl?.trim() &&
+ publishableKey?.trim() &&
+ /^https?:\/\//i.test(baseUrl.trim()),
+ );
+}
+
+function createEmptyPaginatedProductsResponse(
+ params?: ProductListParams,
+): PaginatedResponse {
+ return {
+ data: [],
+ meta: {
+ count: 0,
+ pages: 0,
+ page: Number(params?.page ?? 1),
+ limit: Number(params?.limit ?? 0),
+ from: 0,
+ to: 0,
+ in: [],
+ previous: null,
+ next: null,
+ },
+ } as unknown as PaginatedResponse;
+}
+
+function createEmptyProductFiltersResponse(): ProductFiltersResponse {
+ return {
+ filters: [],
+ sort_options: [],
+ default_sort: "",
+ total_count: 0,
+ } as unknown as ProductFiltersResponse;
+}
/**
* Cached product list fetch. Cache key is derived from all function
@@ -18,17 +68,37 @@ export async function cachedListProducts(
params: ProductListParams | undefined,
options: { locale?: string; country?: string },
_userToken?: string,
-) {
+ baseUrl?: string,
+ publishableKey?: string,
+ _spreeScope?: string,
+): Promise> {
"use cache: remote";
cacheLife("tenMinutes");
cacheTag("products");
- return getClient().products.list(params, options);
+ if (!hasValidSpreeConfig(baseUrl, publishableKey)) {
+ return createEmptyPaginatedProductsResponse(params);
+ }
+
+ return getClientForConfig({
+ baseUrl: baseUrl ?? "",
+ publishableKey: publishableKey ?? "",
+ }).products.list(params, options);
}
-export async function getProducts(params?: ProductListParams) {
+export async function getProducts(
+ params?: ProductListParams,
+): Promise> {
const options = await getLocaleOptions();
const userToken = await getAccessToken();
- return cachedListProducts(params, options, userToken);
+ const spreeConfig = await resolveSpreeConfig();
+ return cachedListProducts(
+ params,
+ options,
+ userToken,
+ spreeConfig.baseUrl,
+ spreeConfig.publishableKey,
+ getSpreeCacheScope(spreeConfig),
+ );
}
/**
@@ -45,35 +115,74 @@ export async function cachedGetProduct(
expand: string[],
options: { locale?: string; country?: string },
_userToken?: string,
-) {
+ baseUrl?: string,
+ publishableKey?: string,
+ _spreeScope?: string,
+): Promise {
"use cache: remote";
cacheLife("tenMinutes");
cacheTag("products", `product:${slugOrId}`);
- return getClient().products.get(slugOrId, { expand }, options);
+ if (!hasValidSpreeConfig(baseUrl, publishableKey)) {
+ throw new Error("Spree client is not configured for this store.");
+ }
+
+ return getClientForConfig({
+ baseUrl: baseUrl ?? "",
+ publishableKey: publishableKey ?? "",
+ }).products.get(slugOrId, { expand }, options);
}
export async function getProduct(
slugOrId: string,
params?: { expand?: string[] },
-) {
+): Promise {
const options = await getLocaleOptions();
const userToken = await getAccessToken();
- return cachedGetProduct(slugOrId, params?.expand ?? [], options, userToken);
+ const spreeConfig = await resolveSpreeConfig();
+ return cachedGetProduct(
+ slugOrId,
+ params?.expand ?? [],
+ options,
+ userToken,
+ spreeConfig.baseUrl,
+ spreeConfig.publishableKey,
+ getSpreeCacheScope(spreeConfig),
+ );
}
async function cachedGetProductFilters(
params: Record | undefined,
options: { locale?: string; country?: string },
_userToken?: string,
-) {
+ baseUrl?: string,
+ publishableKey?: string,
+ _spreeScope?: string,
+): Promise {
"use cache: remote";
cacheLife("tenMinutes");
cacheTag("product-filters");
- return getClient().products.filters(params, options);
+ if (!hasValidSpreeConfig(baseUrl, publishableKey)) {
+ return createEmptyProductFiltersResponse();
+ }
+
+ return getClientForConfig({
+ baseUrl: baseUrl ?? "",
+ publishableKey: publishableKey ?? "",
+ }).products.filters(params, options);
}
-export async function getProductFilters(params?: Record) {
+export async function getProductFilters(
+ params?: Record,
+): Promise {
const options = await getLocaleOptions();
const userToken = await getAccessToken();
- return cachedGetProductFilters(params, options, userToken);
+ const spreeConfig = await resolveSpreeConfig();
+ return cachedGetProductFilters(
+ params,
+ options,
+ userToken,
+ spreeConfig.baseUrl,
+ spreeConfig.publishableKey,
+ getSpreeCacheScope(spreeConfig),
+ );
}
diff --git a/src/lib/homepage/default-homepage.json b/src/lib/homepage/default-homepage.json
new file mode 100644
index 00000000..63d3528c
--- /dev/null
+++ b/src/lib/homepage/default-homepage.json
@@ -0,0 +1,103 @@
+{
+ "version": 1,
+ "sections": [
+ {
+ "type": "hero",
+ "badge": "New season arrivals",
+ "title": "{{storeName}} curated for modern living",
+ "description": "Build a bold first impression with a homepage that highlights your brand story, elevates your best products, and guides shoppers naturally toward conversion.",
+ "alignment": "left",
+ "actionsLayout": "row",
+ "primaryAction": {
+ "label": "Shop Collection",
+ "href": "/products",
+ "variant": "default",
+ "icon": "arrow-right"
+ },
+ "secondaryAction": {
+ "label": "Explore Story",
+ "href": "/products",
+ "variant": "outline",
+ "icon": "play"
+ },
+ "stats": [
+ {
+ "value": "50k+",
+ "label": "Happy customers"
+ },
+ {
+ "value": "4.9/5",
+ "label": "Average rating"
+ },
+ {
+ "value": "120+",
+ "label": "Curated drops"
+ }
+ ],
+ "media": {
+ "imageUrl": "https://images.unsplash.com/photo-1523381210434-271e8be1f52b?auto=format&fit=crop&q=80&w=1200",
+ "alt": "Modern fashion arrangement",
+ "floatingBadgeTitle": "Just shipped",
+ "floatingBadgeLabel": "To Los Angeles, CA",
+ "secondaryBadgeTitle": "Premium",
+ "secondaryBadgeLabel": "Quality selected"
+ },
+ "theme": {
+ "background": "#fffaf5",
+ "foreground": "#111827",
+ "accent": "#f97316",
+ "mutedForeground": "#6b7280",
+ "cardBackground": "#ffffff",
+ "borderColor": "#fed7aa"
+ }
+ },
+ {
+ "type": "features",
+ "title": "Made to convert attention into action",
+ "description": "Use configurable blocks to spotlight what matters most to each tenant without rewriting the page.",
+ "items": [
+ {
+ "title": "Rich visual storytelling",
+ "description": "Highlight branded imagery, campaign copy, and product moments in a layout that feels uniquely yours.",
+ "icon": "sparkles"
+ },
+ {
+ "title": "Fast fulfillment messaging",
+ "description": "Promote delivery promises and merchandising hooks exactly where they have the most impact.",
+ "icon": "truck"
+ },
+ {
+ "title": "Trust at every step",
+ "description": "Surface ratings, guarantees, and support cues to help shoppers buy with confidence.",
+ "icon": "shield"
+ }
+ ],
+ "theme": {
+ "background": "#ffffff",
+ "foreground": "#111827",
+ "mutedForeground": "#6b7280",
+ "cardBackground": "#ffffff",
+ "borderColor": "#e5e7eb",
+ "accent": "#2563eb"
+ }
+ },
+ {
+ "type": "featured-products",
+ "title": "Featured products",
+ "description": "Showcase the products that define this store right now — trending, seasonal, or simply impossible to ignore.",
+ "cta": {
+ "label": "View all products",
+ "href": "/products",
+ "variant": "outline",
+ "icon": "arrow-right"
+ },
+ "theme": {
+ "background": "#f8fafc",
+ "foreground": "#0f172a",
+ "mutedForeground": "#64748b",
+ "accent": "#0f172a",
+ "borderColor": "#cbd5e1"
+ }
+ }
+ ]
+}
diff --git a/src/lib/homepage/index.ts b/src/lib/homepage/index.ts
new file mode 100644
index 00000000..0a8da02f
--- /dev/null
+++ b/src/lib/homepage/index.ts
@@ -0,0 +1,164 @@
+import defaultHomepageConfig from "./default-homepage.json";
+import type { HomepageConfig, HomepageSectionConfig } from "./types";
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function isHomepageSection(value: unknown): value is HomepageSectionConfig {
+ return isRecord(value) && typeof value.type === "string";
+}
+
+function isHomepageConfigLike(
+ value: unknown,
+): value is Partial {
+ return (
+ isRecord(value) &&
+ (value.version === undefined || value.version === 1) &&
+ (value.sections === undefined || Array.isArray(value.sections))
+ );
+}
+
+function interpolateString(
+ template: string,
+ variables: Record,
+): string {
+ return template.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (_match, key: string) => {
+ return variables[key] ?? "";
+ });
+}
+
+function interpolateValue(value: T, variables: Record): T {
+ if (typeof value === "string") {
+ return interpolateString(value, variables) as T;
+ }
+
+ if (Array.isArray(value)) {
+ return value.map((entry) => interpolateValue(entry, variables)) as T;
+ }
+
+ if (value && typeof value === "object") {
+ return Object.fromEntries(
+ Object.entries(value).map(([key, entry]) => [
+ key,
+ interpolateValue(entry, variables),
+ ]),
+ ) as T;
+ }
+
+ return value;
+}
+
+function mergeObjects(base: T, override: unknown): T {
+ if (!isRecord(base) || !isRecord(override)) {
+ return (override ?? base) as T;
+ }
+
+ const result: Record = { ...base };
+
+ for (const [key, overrideValue] of Object.entries(override)) {
+ const baseValue = result[key];
+
+ if (Array.isArray(overrideValue)) {
+ result[key] = overrideValue;
+ continue;
+ }
+
+ if (isRecord(baseValue) && isRecord(overrideValue)) {
+ result[key] = mergeObjects(baseValue, overrideValue);
+ continue;
+ }
+
+ result[key] = overrideValue;
+ }
+
+ return result as T;
+}
+
+function mergeHomepageSections(
+ baseSections: HomepageSectionConfig[],
+ overrideSections: unknown,
+): HomepageSectionConfig[] {
+ if (!Array.isArray(overrideSections)) {
+ return baseSections;
+ }
+
+ const validOverrideSections = overrideSections.filter(isHomepageSection);
+ if (validOverrideSections.length === 0) {
+ return baseSections;
+ }
+
+ const baseSectionsByType = new Map(
+ baseSections.map((section) => [section.type, section]),
+ );
+ const mergedSections = validOverrideSections.map((section) => {
+ const baseSection = baseSectionsByType.get(section.type);
+ return baseSection ? mergeObjects(baseSection, section) : section;
+ });
+
+ return mergedSections;
+}
+
+function extractHomepageSource(source: unknown): unknown {
+ if (!isRecord(source)) {
+ return source;
+ }
+
+ if (isHomepageConfigLike(source)) {
+ return source;
+ }
+
+ const homepage = source.homepage;
+ if (isHomepageConfigLike(homepage)) {
+ return homepage;
+ }
+
+ const design = source.design;
+ if (!isRecord(design)) {
+ return source;
+ }
+
+ const layout = design.layout;
+ if (!isRecord(layout)) {
+ return source;
+ }
+
+ return layout.homepage;
+}
+
+export function getDefaultHomepageConfig(
+ variables: Record,
+): HomepageConfig {
+ return interpolateValue(defaultHomepageConfig as HomepageConfig, variables);
+}
+
+export function getHomepageConfig(
+ variables: Record,
+ source?: unknown,
+): HomepageConfig {
+ const defaultConfig = getDefaultHomepageConfig(variables);
+ const extractedSource = extractHomepageSource(source);
+
+ if (!isHomepageConfigLike(extractedSource)) {
+ return defaultConfig;
+ }
+
+ const overrideConfig = interpolateValue(extractedSource, variables);
+
+ return {
+ ...mergeObjects(defaultConfig, overrideConfig),
+ sections: mergeHomepageSections(
+ defaultConfig.sections,
+ overrideConfig.sections,
+ ),
+ version: 1,
+ };
+}
+
+export function getHomepageSections(
+ config: HomepageConfig,
+): HomepageSectionConfig[] {
+ return config.sections;
+}
+
+export * from "./types";
diff --git a/src/lib/homepage/types.ts b/src/lib/homepage/types.ts
new file mode 100644
index 00000000..e6d2d1dc
--- /dev/null
+++ b/src/lib/homepage/types.ts
@@ -0,0 +1,83 @@
+export type HomepageButtonVariant =
+ | "default"
+ | "outline"
+ | "secondary"
+ | "ghost"
+ | "link";
+export type HomepageActionIcon = "arrow-right" | "play";
+
+export interface HomepageButtonConfig {
+ label: string;
+ href: string;
+ variant?: HomepageButtonVariant;
+ icon?: HomepageActionIcon;
+}
+
+export interface HomepageThemeConfig {
+ background?: string;
+ foreground?: string;
+ accent?: string;
+ mutedForeground?: string;
+ cardBackground?: string;
+ borderColor?: string;
+}
+
+export interface HomepageHeroStat {
+ value: string;
+ label: string;
+}
+
+export interface HomepageHeroMediaConfig {
+ imageUrl?: string;
+ alt?: string;
+ floatingBadgeTitle?: string;
+ floatingBadgeLabel?: string;
+ secondaryBadgeTitle?: string;
+ secondaryBadgeLabel?: string;
+}
+
+export interface HomepageHeroSectionConfig {
+ type: "hero";
+ badge?: string;
+ title: string;
+ description: string;
+ alignment?: "left" | "center";
+ actionsLayout?: "row" | "stack";
+ primaryAction?: HomepageButtonConfig;
+ secondaryAction?: HomepageButtonConfig;
+ stats?: HomepageHeroStat[];
+ media?: HomepageHeroMediaConfig;
+ theme?: HomepageThemeConfig;
+}
+
+export interface HomepageFeatureItemConfig {
+ title: string;
+ description: string;
+ icon?: "sparkles" | "truck" | "shield" | "shopping-bag";
+}
+
+export interface HomepageFeaturesSectionConfig {
+ type: "features";
+ title?: string;
+ description?: string;
+ items: HomepageFeatureItemConfig[];
+ theme?: HomepageThemeConfig;
+}
+
+export interface HomepageFeaturedProductsSectionConfig {
+ type: "featured-products";
+ title: string;
+ description?: string;
+ cta?: HomepageButtonConfig;
+ theme?: HomepageThemeConfig;
+}
+
+export type HomepageSectionConfig =
+ | HomepageHeroSectionConfig
+ | HomepageFeaturesSectionConfig
+ | HomepageFeaturedProductsSectionConfig;
+
+export interface HomepageConfig {
+ version: 1;
+ sections: HomepageSectionConfig[];
+}
diff --git a/src/lib/metadata/category.ts b/src/lib/metadata/category.ts
index ce9a69d3..280b1acf 100644
--- a/src/lib/metadata/category.ts
+++ b/src/lib/metadata/category.ts
@@ -2,18 +2,29 @@ import type { Metadata } from "next";
import { getCachedCategory } from "@/lib/data/cached";
import { buildCanonicalUrl } from "@/lib/seo";
import { getStoreUrl } from "@/lib/store";
+import type { TenantConfig } from "@/lib/tenant";
+import { getTenantConfigFromRequest } from "@/lib/tenant/request";
+import {
+ getTenantBrandName,
+ getTenantDescription,
+ getTenantSiteUrl,
+} from "@/lib/tenant/surface";
export interface CategoryMetadataParams {
country: string;
locale: string;
permalink: string[];
+ tenantConfig?: TenantConfig | null;
}
export async function generateCategoryMetadata({
country,
locale,
permalink,
+ tenantConfig,
}: CategoryMetadataParams): Promise {
+ const resolvedTenantConfig =
+ tenantConfig ?? (await getTenantConfigFromRequest());
const fullPermalink = permalink.join("/");
let category;
@@ -32,7 +43,7 @@ export async function generateCategoryMetadata({
category.description ||
`Browse ${category.name} products.`;
- const storeUrl = getStoreUrl();
+ const storeUrl = getTenantSiteUrl(resolvedTenantConfig) ?? getStoreUrl();
const canonicalUrl = storeUrl
? buildCanonicalUrl(
storeUrl,
@@ -42,12 +53,12 @@ export async function generateCategoryMetadata({
return {
title,
- description,
+ description: getTenantDescription(resolvedTenantConfig) ?? description,
...(category.meta_keywords ? { keywords: category.meta_keywords } : {}),
...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}),
openGraph: {
- title,
- description,
+ title: getTenantBrandName(resolvedTenantConfig) ?? title,
+ description: getTenantDescription(resolvedTenantConfig) ?? description,
...(canonicalUrl ? { url: canonicalUrl } : {}),
type: "website",
...(category.image_url
diff --git a/src/lib/metadata/home.ts b/src/lib/metadata/home.ts
index 9a88ed3a..4228efd8 100644
--- a/src/lib/metadata/home.ts
+++ b/src/lib/metadata/home.ts
@@ -5,19 +5,32 @@ import {
getStoreSeoTitle,
getStoreUrl,
} from "@/lib/store";
+import type { TenantConfig } from "@/lib/tenant";
+import { getTenantConfigFromRequest } from "@/lib/tenant/request";
+import {
+ getTenantBrandName,
+ getTenantDescription,
+ getTenantSiteUrl,
+} from "@/lib/tenant/surface";
interface HomeMetadataParams {
country: string;
locale: string;
+ tenantConfig?: TenantConfig | null;
}
export async function generateHomeMetadata({
country,
locale,
+ tenantConfig,
}: HomeMetadataParams): Promise {
- const storeName = getStoreSeoTitle();
- const description = getStoreMetaDescription();
- const storeUrl = getStoreUrl();
+ const resolvedTenantConfig =
+ tenantConfig ?? (await getTenantConfigFromRequest());
+ const storeName =
+ getTenantBrandName(resolvedTenantConfig) ?? getStoreSeoTitle();
+ const description =
+ getTenantDescription(resolvedTenantConfig) ?? getStoreMetaDescription();
+ const storeUrl = getTenantSiteUrl(resolvedTenantConfig) ?? getStoreUrl();
const canonicalUrl = storeUrl
? buildCanonicalUrl(storeUrl, `/${country}/${locale}`)
: undefined;
diff --git a/src/lib/metadata/product.ts b/src/lib/metadata/product.ts
index 279ee3be..f2b031ff 100644
--- a/src/lib/metadata/product.ts
+++ b/src/lib/metadata/product.ts
@@ -2,18 +2,25 @@ import type { Metadata } from "next";
import { getCachedProduct, PRODUCT_METADATA_EXPAND } from "@/lib/data/cached";
import { buildCanonicalUrl, stripHtml } from "@/lib/seo";
import { getStoreUrl } from "@/lib/store";
+import type { TenantConfig } from "@/lib/tenant";
+import { getTenantConfigFromRequest } from "@/lib/tenant/request";
+import { getTenantSiteUrl } from "@/lib/tenant/surface";
interface ProductMetadataParams {
country: string;
locale: string;
slug: string;
+ tenantConfig?: TenantConfig | null;
}
export async function generateProductMetadata({
country,
locale,
slug,
+ tenantConfig,
}: ProductMetadataParams): Promise {
+ const resolvedTenantConfig =
+ tenantConfig ?? (await getTenantConfigFromRequest());
let product;
try {
product = await getCachedProduct(slug, PRODUCT_METADATA_EXPAND);
@@ -28,7 +35,7 @@ export async function generateProductMetadata({
? stripHtml(product.description).slice(0, 160)
: `Shop ${product.name}`;
- const storeUrl = getStoreUrl();
+ const storeUrl = getTenantSiteUrl(resolvedTenantConfig) ?? getStoreUrl();
const canonicalUrl = storeUrl
? buildCanonicalUrl(
storeUrl,
diff --git a/src/lib/metadata/products.ts b/src/lib/metadata/products.ts
index 66fbd8bc..9d603333 100644
--- a/src/lib/metadata/products.ts
+++ b/src/lib/metadata/products.ts
@@ -1,17 +1,24 @@
import type { Metadata } from "next";
import { buildCanonicalUrl } from "@/lib/seo";
import { getStoreUrl } from "@/lib/store";
+import type { TenantConfig } from "@/lib/tenant";
+import { getTenantConfigFromRequest } from "@/lib/tenant/request";
+import { getTenantSiteUrl } from "@/lib/tenant/surface";
interface ProductsMetadataParams {
country: string;
locale: string;
+ tenantConfig?: TenantConfig | null;
}
export async function generateProductsMetadata({
country,
locale,
+ tenantConfig,
}: ProductsMetadataParams): Promise {
- const storeUrl = getStoreUrl();
+ const resolvedTenantConfig =
+ tenantConfig ?? (await getTenantConfigFromRequest());
+ const storeUrl = getTenantSiteUrl(resolvedTenantConfig) ?? getStoreUrl();
const canonicalUrl = storeUrl
? buildCanonicalUrl(storeUrl, `/${country}/${locale}/products`)
: undefined;
diff --git a/src/lib/metadata/store.ts b/src/lib/metadata/store.ts
index f3d84ec8..d081d72c 100644
--- a/src/lib/metadata/store.ts
+++ b/src/lib/metadata/store.ts
@@ -6,6 +6,13 @@ import {
getStoreSeoTitle,
getStoreUrl,
} from "@/lib/store";
+import type { TenantConfig } from "@/lib/tenant";
+import {
+ getTenantBrandName,
+ getTenantDescription,
+ getTenantSiteUrl,
+ getTenantTwitterHandle,
+} from "@/lib/tenant/surface";
function normalizeOpenGraphLocale(locale: string): string {
const parts = locale.split(/[-_]/);
@@ -15,16 +22,21 @@ function normalizeOpenGraphLocale(locale: string): string {
interface StoreMetadataParams {
locale: string;
+ tenantConfig?: TenantConfig | null;
}
export async function generateStoreMetadata({
locale,
+ tenantConfig,
}: StoreMetadataParams): Promise {
- const storeName = getStoreSeoTitle();
- const storeUrl = getStoreUrl();
- const metaDescription = getStoreMetaDescription();
+ const storeName =
+ getTenantBrandName(tenantConfig) ?? getStoreSeoTitle() ?? getStoreName();
+ const storeUrl = getTenantSiteUrl(tenantConfig) ?? getStoreUrl();
+ const metaDescription =
+ getTenantDescription(tenantConfig) ?? getStoreMetaDescription();
const metaKeywords = process.env.STORE_META_KEYWORDS;
- const twitter = process.env.STORE_TWITTER;
+ const twitter =
+ getTenantTwitterHandle(tenantConfig) ?? process.env.STORE_TWITTER;
let metadataBaseSpread: Partial<{ metadataBase: URL }> = {};
if (storeUrl) {
@@ -44,7 +56,7 @@ export async function generateStoreMetadata({
description: metaDescription,
...(metaKeywords ? { keywords: metaKeywords } : {}),
openGraph: {
- siteName: getStoreName(),
+ siteName: storeName,
locale: normalizeOpenGraphLocale(locale),
type: "website",
images: [SOCIAL_IMAGE_PATH],
diff --git a/src/lib/page-builder/default-pages.json b/src/lib/page-builder/default-pages.json
new file mode 100644
index 00000000..b41b6911
--- /dev/null
+++ b/src/lib/page-builder/default-pages.json
@@ -0,0 +1,107 @@
+{
+ "version": 1,
+ "pages": [
+ {
+ "slug": "about/brand-story",
+ "title": "About {{storeName}}",
+ "description": "Learn the story behind {{storeName}} and the values that shape every collection.",
+ "seo": {
+ "title": "About {{storeName}}",
+ "description": "Discover the story, mission, and point of view behind {{storeName}}."
+ },
+ "sections": [
+ {
+ "type": "hero",
+ "badge": "Brand story",
+ "title": "A distinct page built at runtime",
+ "description": "This page is rendered from configuration only. Change the slug, blocks, text, buttons, and visuals without creating a new route file.",
+ "alignment": "left",
+ "actionsLayout": "row",
+ "primaryAction": {
+ "label": "Shop products",
+ "href": "/products",
+ "variant": "default",
+ "icon": "arrow-right"
+ },
+ "secondaryAction": {
+ "label": "Back home",
+ "href": "/",
+ "variant": "outline"
+ },
+ "media": {
+ "imageUrl": "https://images.unsplash.com/photo-1483985988355-763728e1935b?auto=format&fit=crop&q=80&w=1200",
+ "alt": "Editorial fashion scene"
+ },
+ "theme": {
+ "background": "#f8fafc",
+ "foreground": "#0f172a",
+ "accent": "#2563eb",
+ "mutedForeground": "#475569",
+ "cardBackground": "#ffffff",
+ "borderColor": "#cbd5e1"
+ }
+ },
+ {
+ "type": "rich-text",
+ "eyebrow": "Why it matters",
+ "title": "Composable sections, safe rendering",
+ "body": [
+ "Each dynamic page is built from approved section types, so tenants can create new pages at runtime without shipping custom JSX.",
+ "That keeps your storefront flexible for content teams while preserving type safety, branding consistency, and rendering performance."
+ ],
+ "alignment": "left",
+ "cta": {
+ "label": "View homepage",
+ "href": "/",
+ "variant": "link"
+ },
+ "theme": {
+ "background": "#ffffff",
+ "foreground": "#111827",
+ "mutedForeground": "#6b7280",
+ "accent": "#2563eb"
+ }
+ },
+ {
+ "type": "image-banner",
+ "eyebrow": "Campaign block",
+ "title": "A runtime-defined promotional banner",
+ "description": "Use this section for launches, editorial campaigns, seasonal messages, or partner callouts.",
+ "imageUrl": "https://images.unsplash.com/photo-1441986300917-64674bd600d8?auto=format&fit=crop&q=80&w=1600",
+ "imageAlt": "Store campaign banner",
+ "height": "md",
+ "overlay": true,
+ "cta": {
+ "label": "Browse collection",
+ "href": "/products",
+ "variant": "secondary",
+ "icon": "arrow-right"
+ },
+ "theme": {
+ "foreground": "#ffffff",
+ "accent": "#ffffff",
+ "mutedForeground": "#e5e7eb"
+ }
+ },
+ {
+ "type": "featured-products",
+ "title": "Featured products",
+ "description": "The same merchandising block can appear on any runtime-configured page.",
+ "cta": {
+ "label": "Explore all products",
+ "href": "/products",
+ "variant": "outline",
+ "icon": "arrow-right"
+ },
+ "theme": {
+ "background": "#f8fafc",
+ "foreground": "#0f172a",
+ "mutedForeground": "#64748b",
+ "accent": "#0f172a",
+ "borderColor": "#cbd5e1"
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/src/lib/page-builder/index.ts b/src/lib/page-builder/index.ts
new file mode 100644
index 00000000..e16acf61
--- /dev/null
+++ b/src/lib/page-builder/index.ts
@@ -0,0 +1,238 @@
+import defaultPagesConfig from "./default-pages.json";
+import type { DynamicPageConfig, DynamicPagesConfig } from "./types";
+
+function interpolateString(
+ template: string,
+ variables: Record,
+): string {
+ return template.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (_match, key: string) => {
+ return variables[key] ?? "";
+ });
+}
+
+function interpolateValue(value: T, variables: Record): T {
+ if (typeof value === "string") {
+ return interpolateString(value, variables) as T;
+ }
+
+ if (Array.isArray(value)) {
+ return value.map((entry) => interpolateValue(entry, variables)) as T;
+ }
+
+ if (value && typeof value === "object") {
+ return Object.fromEntries(
+ Object.entries(value).map(([key, entry]) => [
+ key,
+ interpolateValue(entry, variables),
+ ]),
+ ) as T;
+ }
+
+ return value;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function isDynamicPage(value: unknown): value is DynamicPageConfig {
+ if (!isRecord(value)) return false;
+ return typeof value.slug === "string" && Array.isArray(value.sections);
+}
+
+function isDynamicPageLike(
+ value: unknown,
+): value is Partial {
+ if (!isRecord(value)) return false;
+ return typeof value.slug === "string";
+}
+
+function isDynamicPagesConfig(value: unknown): value is DynamicPagesConfig {
+ if (!isRecord(value)) return false;
+ return (
+ value.version === 1 &&
+ Array.isArray(value.pages) &&
+ value.pages.every(isDynamicPage)
+ );
+}
+
+function isDynamicPagesConfigLike(
+ value: unknown,
+): value is Partial {
+ return (
+ isRecord(value) &&
+ (value.version === undefined || value.version === 1) &&
+ (value.pages === undefined || Array.isArray(value.pages))
+ );
+}
+
+function normalizeSlug(value: string | string[]): string {
+ const joined = Array.isArray(value) ? value.join("/") : value;
+ return joined
+ .split("/")
+ .map((segment) => segment.trim())
+ .filter(Boolean)
+ .join("/")
+ .toLowerCase();
+}
+
+function mergeObjects(base: T, override: unknown): T {
+ if (!isRecord(base) || !isRecord(override)) {
+ return (override ?? base) as T;
+ }
+
+ const result: Record = { ...base };
+
+ for (const [key, overrideValue] of Object.entries(override)) {
+ const baseValue = result[key];
+
+ if (Array.isArray(overrideValue)) {
+ result[key] = overrideValue;
+ continue;
+ }
+
+ if (isRecord(baseValue) && isRecord(overrideValue)) {
+ result[key] = mergeObjects(baseValue, overrideValue);
+ continue;
+ }
+
+ result[key] = overrideValue;
+ }
+
+ return result as T;
+}
+
+function extractDynamicPagesSource(source: unknown): unknown {
+ if (!isRecord(source)) {
+ return source;
+ }
+
+ if (isDynamicPagesConfigLike(source)) {
+ return source;
+ }
+
+ if (isDynamicPagesConfigLike(source.dynamicPages)) {
+ return source.dynamicPages;
+ }
+
+ if (Array.isArray(source.pages)) {
+ return { version: 1, pages: source.pages };
+ }
+
+ const design = source.design;
+ if (!isRecord(design)) {
+ return source;
+ }
+
+ const layout = design.layout;
+ if (!isRecord(layout)) {
+ return source;
+ }
+
+ if (Array.isArray(layout.pages)) {
+ return { version: 1, pages: layout.pages };
+ }
+
+ return source;
+}
+
+function mergeDynamicPages(
+ basePages: DynamicPageConfig[],
+ overridePages: unknown,
+): DynamicPageConfig[] {
+ if (!Array.isArray(overridePages)) {
+ return basePages;
+ }
+
+ const mergedBySlug = new Map();
+
+ for (const page of basePages) {
+ mergedBySlug.set(normalizeSlug(page.slug), page);
+ }
+
+ for (const candidate of overridePages) {
+ if (!isDynamicPageLike(candidate)) continue;
+ if (typeof candidate.slug !== "string") continue;
+
+ const slug = normalizeSlug(candidate.slug);
+ if (!slug) continue;
+
+ const basePage = mergedBySlug.get(slug);
+ const nextPage = basePage ? mergeObjects(basePage, candidate) : candidate;
+
+ if (Array.isArray(candidate.sections)) {
+ nextPage.sections = candidate.sections as DynamicPageConfig["sections"];
+ }
+
+ if (!Array.isArray(nextPage.sections)) continue;
+
+ mergedBySlug.set(slug, {
+ slug,
+ title: nextPage.title ?? slug,
+ description: nextPage.description,
+ seo: nextPage.seo,
+ sections: nextPage.sections,
+ });
+ }
+
+ return Array.from(mergedBySlug.values());
+}
+
+export function getDefaultDynamicPagesConfig(
+ variables: Record,
+): DynamicPagesConfig {
+ return interpolateValue(defaultPagesConfig as DynamicPagesConfig, variables);
+}
+
+export function getDynamicPagesConfig(
+ variables: Record,
+ source?: unknown,
+): DynamicPagesConfig {
+ const defaultConfig = getDefaultDynamicPagesConfig(variables);
+ const extractedSource = extractDynamicPagesSource(source);
+
+ if (isDynamicPagesConfig(extractedSource)) {
+ return {
+ ...mergeObjects(
+ defaultConfig,
+ interpolateValue(extractedSource, variables),
+ ),
+ pages: mergeDynamicPages(
+ defaultConfig.pages,
+ interpolateValue(extractedSource.pages, variables),
+ ),
+ version: 1,
+ };
+ }
+
+ if (isDynamicPagesConfigLike(extractedSource)) {
+ const interpolatedSource = interpolateValue(extractedSource, variables);
+
+ return {
+ ...mergeObjects(defaultConfig, interpolatedSource),
+ pages: mergeDynamicPages(defaultConfig.pages, interpolatedSource.pages),
+ version: 1,
+ };
+ }
+
+ return defaultConfig;
+}
+
+export function resolveDynamicPage(
+ config: DynamicPagesConfig,
+ slug: string | string[],
+): DynamicPageConfig | null {
+ const normalizedSlug = normalizeSlug(slug);
+ if (!normalizedSlug) return null;
+
+ return (
+ config.pages.find((page) => normalizeSlug(page.slug) === normalizedSlug) ??
+ null
+ );
+}
+
+export function listDynamicPageSlugs(config: DynamicPagesConfig): string[] {
+ return config.pages.map((page) => normalizeSlug(page.slug));
+}
+
+export * from "./types";
diff --git a/src/lib/page-builder/types.ts b/src/lib/page-builder/types.ts
new file mode 100644
index 00000000..fdcba573
--- /dev/null
+++ b/src/lib/page-builder/types.ts
@@ -0,0 +1,55 @@
+import type {
+ HomepageButtonConfig,
+ HomepageFeaturedProductsSectionConfig,
+ HomepageFeaturesSectionConfig,
+ HomepageHeroSectionConfig,
+ HomepageThemeConfig,
+} from "@/lib/homepage";
+
+export interface DynamicPageSeoConfig {
+ title?: string;
+ description?: string;
+}
+
+export interface DynamicPageRichTextSectionConfig {
+ type: "rich-text";
+ eyebrow?: string;
+ title?: string;
+ body: string[];
+ alignment?: "left" | "center";
+ cta?: HomepageButtonConfig;
+ theme?: HomepageThemeConfig;
+}
+
+export interface DynamicPageImageBannerSectionConfig {
+ type: "image-banner";
+ eyebrow?: string;
+ title: string;
+ description?: string;
+ imageUrl: string;
+ imageAlt?: string;
+ height?: "sm" | "md" | "lg";
+ overlay?: boolean;
+ cta?: HomepageButtonConfig;
+ theme?: HomepageThemeConfig;
+}
+
+export type DynamicPageSectionConfig =
+ | HomepageHeroSectionConfig
+ | HomepageFeaturesSectionConfig
+ | HomepageFeaturedProductsSectionConfig
+ | DynamicPageRichTextSectionConfig
+ | DynamicPageImageBannerSectionConfig;
+
+export interface DynamicPageConfig {
+ slug: string;
+ title: string;
+ description?: string;
+ seo?: DynamicPageSeoConfig;
+ sections: DynamicPageSectionConfig[];
+}
+
+export interface DynamicPagesConfig {
+ version: 1;
+ pages: DynamicPageConfig[];
+}
diff --git a/src/lib/seo.ts b/src/lib/seo.ts
index 186f9a51..5eeb05a7 100644
--- a/src/lib/seo.ts
+++ b/src/lib/seo.ts
@@ -1,5 +1,14 @@
import type { Category, Media, Product } from "@spree/sdk";
import { ensureProtocol, getStoreName, getStoreUrl } from "@/lib/store";
+import type { TenantConfig } from "@/lib/tenant";
+import {
+ getTenantBrandName,
+ getTenantDescription,
+ getTenantLogoUrl,
+ getTenantSiteUrl,
+ getTenantSocialLinks,
+ getTenantTwitterHandle,
+} from "@/lib/tenant/surface";
/**
* Default social image path (stored in public/).
@@ -128,14 +137,22 @@ export function buildBreadcrumbJsonLd(
* Build JSON-LD Organization schema from environment variables.
* https://schema.org/Organization
*/
-export function buildOrganizationJsonLd(): Record {
- const storeName = getStoreName();
- const storeUrl = getStoreUrl();
- const logoUrl = process.env.STORE_LOGO_URL;
+export function buildOrganizationJsonLd(
+ tenantConfig?: TenantConfig | null,
+): Record {
+ const storeName =
+ getTenantBrandName(tenantConfig) ?? getStoreName() ?? "Store";
+ const storeUrl = getTenantSiteUrl(tenantConfig) ?? getStoreUrl();
+ const logoUrl = getTenantLogoUrl(tenantConfig) ?? process.env.STORE_LOGO_URL;
+ const twitter =
+ getTenantTwitterHandle(tenantConfig) ?? process.env.STORE_TWITTER;
+ const socialLinks = getTenantSocialLinks(tenantConfig);
const facebook = process.env.STORE_FACEBOOK;
- const twitter = process.env.STORE_TWITTER;
const instagram = process.env.STORE_INSTAGRAM;
- const supportEmail = process.env.STORE_SUPPORT_EMAIL;
+ const supportEmail =
+ getTenantDescription(tenantConfig) && process.env.STORE_SUPPORT_EMAIL
+ ? process.env.STORE_SUPPORT_EMAIL
+ : process.env.STORE_SUPPORT_EMAIL;
const schema: Record = {
"@context": "https://schema.org",
@@ -148,7 +165,7 @@ export function buildOrganizationJsonLd(): Record {
schema.logo = logoUrl;
}
- const sameAs: string[] = [];
+ const sameAs: string[] = [...socialLinks];
if (facebook) sameAs.push(facebook);
if (twitter) {
sameAs.push(
diff --git a/src/lib/spree/config.test.ts b/src/lib/spree/config.test.ts
new file mode 100644
index 00000000..d6053d5a
--- /dev/null
+++ b/src/lib/spree/config.test.ts
@@ -0,0 +1,100 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ createClient: vi.fn(),
+ getTenantConfigFromRequest: vi.fn(),
+}));
+
+vi.mock("@/lib/tenant/request", () => ({
+ getTenantConfigFromRequest: mocks.getTenantConfigFromRequest,
+}));
+
+vi.mock("@spree/sdk", () => ({
+ createClient: mocks.createClient,
+}));
+
+import {
+ getClient,
+ getSpreeCacheScope,
+ resetClient,
+ resolveSpreeConfig,
+} from "./config";
+
+describe("spree config", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.unstubAllEnvs();
+ resetClient();
+
+ mocks.createClient.mockImplementation(
+ ({
+ baseUrl,
+ publishableKey,
+ }: {
+ baseUrl: string;
+ publishableKey: string;
+ }) => ({
+ markets: {
+ list: vi.fn().mockResolvedValue({ baseUrl, publishableKey }),
+ },
+ }),
+ );
+ });
+
+ it("prefers tenant Spree credentials over env defaults", async () => {
+ mocks.getTenantConfigFromRequest.mockResolvedValue({
+ defaultCountry: "gb",
+ defaultLocale: "en-GB",
+ spree: {
+ apiUrl: "https://tenant.example.com",
+ publishableKey: "tenant-key",
+ },
+ });
+
+ await expect(resolveSpreeConfig()).resolves.toMatchObject({
+ baseUrl: "https://tenant.example.com",
+ publishableKey: "tenant-key",
+ defaultCountry: "gb",
+ defaultLocale: "en-GB",
+ });
+ });
+
+ it("routes client calls through the tenant-specific Spree client", async () => {
+ mocks.getTenantConfigFromRequest.mockResolvedValue({
+ spree: {
+ apiUrl: "https://tenant.example.com",
+ publishableKey: "tenant-key",
+ },
+ });
+
+ await expect(getClient().markets.list({ locale: "en" })).resolves.toEqual({
+ baseUrl: "https://tenant.example.com",
+ publishableKey: "tenant-key",
+ });
+ expect(mocks.createClient).toHaveBeenCalledWith({
+ baseUrl: "https://tenant.example.com",
+ publishableKey: "tenant-key",
+ });
+ });
+
+ it("builds cache scope from the active tenant credentials", async () => {
+ const config = {
+ baseUrl: "https://tenant.example.com",
+ publishableKey: "tenant-key",
+ defaultCountry: "us",
+ defaultLocale: "en",
+ };
+
+ expect(getSpreeCacheScope(config)).toBe(
+ "https://tenant.example.com::tenant-key",
+ );
+ });
+
+ it("does not silently fall back when tenant resolution throws", async () => {
+ mocks.getTenantConfigFromRequest.mockRejectedValue(
+ new Error("olitt unavailable"),
+ );
+
+ await expect(resolveSpreeConfig()).rejects.toThrow("olitt unavailable");
+ });
+});
diff --git a/src/lib/spree/config.ts b/src/lib/spree/config.ts
index 92cecdff..17af4ffe 100644
--- a/src/lib/spree/config.ts
+++ b/src/lib/spree/config.ts
@@ -1,8 +1,120 @@
import { type Client, createClient } from "@spree/sdk";
+import { getTenantConfigFromRequest } from "@/lib/tenant/request";
import type { SpreeNextConfig } from "./types";
-let _client: Client | null = null;
let _config: SpreeNextConfig | null = null;
+let _clientProxy: Client | null = null;
+
+const resolvedClients = new Map();
+
+function buildEnvConfig(): SpreeNextConfig {
+ const baseUrl = process.env.SPREE_API_URL;
+ const publishableKey = process.env.SPREE_PUBLISHABLE_KEY;
+
+ if (!baseUrl || !publishableKey) {
+ throw new Error(
+ "Spree client is not configured. Either call initSpreeNext() or set SPREE_API_URL and SPREE_PUBLISHABLE_KEY environment variables.",
+ );
+ }
+
+ return {
+ baseUrl,
+ publishableKey,
+ defaultCountry: (
+ process.env.NEXT_PUBLIC_DEFAULT_COUNTRY || "us"
+ ).toLowerCase(),
+ defaultLocale: process.env.NEXT_PUBLIC_DEFAULT_LOCALE || "en",
+ };
+}
+
+function getBaseConfig(): SpreeNextConfig {
+ return _config ?? buildEnvConfig();
+}
+
+function getClientCacheKey(config: SpreeNextConfig): string {
+ return `${config.baseUrl}::${config.publishableKey}`;
+}
+
+export function getClientForConfig(config: SpreeNextConfig): Client {
+ const cacheKey = getClientCacheKey(config);
+ const existingClient = resolvedClients.get(cacheKey);
+
+ if (existingClient) {
+ return existingClient;
+ }
+
+ const client = createClient({
+ baseUrl: config.baseUrl,
+ publishableKey: config.publishableKey,
+ });
+
+ resolvedClients.set(cacheKey, client);
+
+ return client;
+}
+
+export async function resolveSpreeConfig(): Promise {
+ const tenantConfig = await getTenantConfigFromRequest();
+
+ if (tenantConfig?.spree.apiUrl && tenantConfig.spree.publishableKey) {
+ return {
+ baseUrl: tenantConfig.spree.apiUrl,
+ publishableKey: tenantConfig.spree.publishableKey,
+ defaultCountry: tenantConfig.defaultCountry,
+ defaultLocale: tenantConfig.defaultLocale,
+ };
+ }
+
+ throw new Error(
+ "Tenant Spree config could not be resolved for this request.",
+ );
+}
+
+export function getSpreeCacheScope(config: SpreeNextConfig): string {
+ return getClientCacheKey(config);
+}
+
+async function resolveClient(): Promise {
+ return getClientForConfig(await resolveSpreeConfig());
+}
+
+function resolvePropertyPath(target: unknown, path: PropertyKey[]) {
+ let parent: unknown;
+ let current = target;
+
+ for (const key of path) {
+ parent = current;
+ current = (current as Record)[key];
+ }
+
+ return { parent, current };
+}
+
+function createClientProxy(path: PropertyKey[] = []): Client {
+ const proxyTarget = (() => undefined) as unknown as Client;
+
+ return new Proxy(proxyTarget, {
+ get(_target, property) {
+ if (path.length === 0 && property === "then") {
+ return undefined;
+ }
+
+ return createClientProxy([...path, property]);
+ },
+ apply(_target, _thisArg, args) {
+ return (async () => {
+ const client = await resolveClient();
+ const { parent, current } = resolvePropertyPath(client, path);
+
+ if (typeof current !== "function") {
+ return current;
+ }
+
+ return current.apply(parent, args);
+ })();
+ },
+ }) as Client;
+}
/**
* Initialize the Spree Next.js integration.
@@ -11,44 +123,32 @@ let _config: SpreeNextConfig | null = null;
*/
export function initSpreeNext(config: SpreeNextConfig): void {
_config = config;
- _client = createClient({
- baseUrl: config.baseUrl,
- publishableKey: config.publishableKey,
- });
+ getClientForConfig(config);
}
/**
* Get the Client instance. Auto-initializes from env vars if needed.
*/
export function getClient(): Client {
- if (!_client) {
- const baseUrl = process.env.SPREE_API_URL;
- const publishableKey = process.env.SPREE_PUBLISHABLE_KEY;
- if (baseUrl && publishableKey) {
- initSpreeNext({ baseUrl, publishableKey });
- } else {
- throw new Error(
- "Spree client is not configured. Either call initSpreeNext() or set SPREE_API_URL and SPREE_PUBLISHABLE_KEY environment variables.",
- );
- }
+ if (!_clientProxy) {
+ _clientProxy = createClientProxy();
}
- return _client!;
+
+ return _clientProxy;
}
/**
* Get the current config. Auto-initializes from env vars if needed.
*/
export function getConfig(): SpreeNextConfig {
- if (!_config) {
- getClient(); // triggers auto-init
- }
- return _config!;
+ return getBaseConfig();
}
/**
* Reset the client (useful for testing).
*/
export function resetClient(): void {
- _client = null;
_config = null;
+ _clientProxy = null;
+ resolvedClients.clear();
}
diff --git a/src/lib/spree/index.ts b/src/lib/spree/index.ts
index 5247db22..598e0440 100644
--- a/src/lib/spree/index.ts
+++ b/src/lib/spree/index.ts
@@ -2,7 +2,14 @@
// Auth helpers (token refresh, cookie-based auth)
export { getAuthOptions, withAuthRefresh } from "./auth-helpers";
-export { getClient, getConfig, initSpreeNext } from "./config";
+export {
+ getClient,
+ getClientForConfig,
+ getConfig,
+ getSpreeCacheScope,
+ initSpreeNext,
+ resolveSpreeConfig,
+} from "./config";
// Cookie management
export {
clearAccessToken,
diff --git a/src/lib/spree/locale.ts b/src/lib/spree/locale.ts
index f98fe7b8..523840d7 100644
--- a/src/lib/spree/locale.ts
+++ b/src/lib/spree/locale.ts
@@ -1,5 +1,5 @@
import { cookies } from "next/headers";
-import { getConfig } from "./config";
+import { resolveSpreeConfig } from "./config";
const DEFAULT_COUNTRY_COOKIE = "spree_country";
const DEFAULT_LOCALE_COOKIE = "spree_locale";
@@ -12,7 +12,7 @@ export async function getLocaleOptions(): Promise<{
locale?: string;
country?: string;
}> {
- const config = getConfig();
+ const config = await resolveSpreeConfig();
try {
const cookieStore = await cookies();
diff --git a/src/lib/store.ts b/src/lib/store.ts
index f2f6f9e5..7627d1b2 100644
--- a/src/lib/store.ts
+++ b/src/lib/store.ts
@@ -37,17 +37,14 @@ export function getStoreUrl(): string | undefined {
* Get the store name from environment variables.
*/
export function getStoreName(): string {
- return process.env.NEXT_PUBLIC_STORE_NAME || "Spree Store";
+ return process.env.NEXT_PUBLIC_STORE_NAME || "Olitt Store";
}
/**
* Get the store description from environment variables.
*/
export function getStoreDescription(): string {
- return (
- process.env.NEXT_PUBLIC_STORE_DESCRIPTION ||
- "A modern e-commerce storefront powered by Spree Commerce and Next.js."
- );
+ return process.env.NEXT_PUBLIC_STORE_DESCRIPTION || "Powered by Olitt.";
}
/**
@@ -69,7 +66,7 @@ export function getDefaultLocale(): string {
* store name (NEXT_PUBLIC_STORE_NAME).
*/
export function getStoreSeoTitle(): string {
- return process.env.STORE_SEO_TITLE || getStoreName();
+ return getStoreName();
}
/**
@@ -77,7 +74,7 @@ export function getStoreSeoTitle(): string {
* back to the store description (NEXT_PUBLIC_STORE_DESCRIPTION).
*/
export function getStoreMetaDescription(): string {
- return process.env.STORE_META_DESCRIPTION || getStoreDescription();
+ return getStoreDescription();
}
/**
diff --git a/src/lib/tenant/__tests__/olitt.test.ts b/src/lib/tenant/__tests__/olitt.test.ts
new file mode 100644
index 00000000..02411e09
--- /dev/null
+++ b/src/lib/tenant/__tests__/olitt.test.ts
@@ -0,0 +1,213 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ buildTenantConfigFromRecord,
+ findOlittStoreRecord,
+ normalizeHost,
+} from "../normalize";
+import { fetchTenantConfigFromOlitt } from "../olitt";
+
+describe("tenant Olitt config helpers", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllEnvs();
+ });
+
+ it("normalizes hosts by removing protocol, port, and path", () => {
+ expect(normalizeHost("HTTPS://Shop.Example.com:3000/products")).toBe(
+ "shop.example.com",
+ );
+ expect(normalizeHost("demo.localhost:3001")).toBe("demo.localhost");
+ expect(normalizeHost("demo.localhost:3001", { preservePort: true })).toBe(
+ "demo.localhost:3001",
+ );
+ expect(normalizeHost(" ")).toBeNull();
+ });
+
+ it("finds a store record in an Olitt payload", () => {
+ const payload = {
+ store: {
+ olittDomain: "beta.example.com",
+ customDomain: "www.beta.example.com",
+ name: "Beta Store",
+ },
+ };
+
+ expect(findOlittStoreRecord(payload, "beta.example.com")).toEqual({
+ olittDomain: "beta.example.com",
+ customDomain: "www.beta.example.com",
+ name: "Beta Store",
+ });
+ });
+
+ it("builds a normalized tenant config from an Olitt record", () => {
+ const config = buildTenantConfigFromRecord(
+ {
+ id: "tenant-123",
+ olittDomain: "brand.example.com",
+ name: "Brand Store",
+ description: "Great products",
+ defaultCountry: "us",
+ defaultLocale: "en",
+ spreeApiUrl: "https://spree.example.com",
+ spreePublishableKey: "spree-pk",
+ paymentKeys: {
+ stripePublishableKey: "pk_test_123",
+ },
+ theme: { colors: { primary: "#000000" } },
+ },
+ "brand.example.com:3000",
+ );
+
+ expect(config).toMatchObject({
+ tenantId: "tenant-123",
+ host: "brand.example.com:3000",
+ storeName: "Brand Store",
+ storeDescription: "Great products",
+ defaultCountry: "us",
+ defaultLocale: "en",
+ spree: {
+ apiUrl: "https://spree.example.com",
+ publishableKey: "spree-pk",
+ },
+ paymentKeys: {
+ stripePublishableKey: "pk_test_123",
+ },
+ source: "olitt",
+ });
+ });
+
+ it("reads Spree config from the documented Olitt root fields", () => {
+ const config = buildTenantConfigFromRecord(
+ {
+ olittDomain: "brand.example.com",
+ spreeApiUrl: "https://tenant-spree.example.com",
+ spreePublishableKey: "tenant-key",
+ },
+ "brand.example.com",
+ );
+
+ expect(config.spree).toEqual({
+ apiUrl: "https://tenant-spree.example.com",
+ publishableKey: "tenant-key",
+ });
+ });
+
+ it("uses a mocked Olitt response for a local host", async () => {
+ vi.stubEnv("OLITT_API_URL", "https://olitt.example.com");
+ vi.stubEnv(
+ "OLITT_MOCK_RESPONSES",
+ JSON.stringify([
+ {
+ host: "testshop.localhost:3001",
+ response: {
+ store: {
+ olittDomain: "testshop.localhost:5000",
+ name: "Mock Store",
+ description: "Mock store description",
+ defaultCountry: "us",
+ defaultLocale: "en-us",
+ spreeApiUrl: "https://spree.example.com",
+ spreePublishableKey: "spree-public-key",
+ paymentKeys: {
+ stripePublishableKey: "pk_test_mock",
+ },
+ branding: {
+ logo: null,
+ name: "Mock Store",
+ tagline: "Mock tagline",
+ social_links: [],
+ },
+ theme: {
+ colors: {},
+ spacing: {},
+ custom_css: "",
+ typography: {},
+ },
+ design: {
+ layout: { pages: [], footer: {}, sections: [], navigation: [] },
+ style: {
+ colors: {},
+ spacing: {},
+ custom_css: "",
+ typography: {},
+ },
+ },
+ seo: {
+ socials: [],
+ description: "",
+ siteUrl: "https://testshop.localhost:5000",
+ },
+ navigation: { links: [] },
+ },
+ },
+ },
+ ]),
+ );
+
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: async () => null,
+ } as Response);
+
+ const config = await fetchTenantConfigFromOlitt("testshop.localhost:3001");
+
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(config).toEqual(
+ expect.objectContaining({
+ host: "testshop.localhost:5000",
+ storeName: "Mock Store",
+ storeDescription: "Mock store description",
+ defaultCountry: "us",
+ defaultLocale: "en-us",
+ spree: {
+ apiUrl: "https://spree.example.com",
+ publishableKey: "spree-public-key",
+ },
+ paymentKeys: {
+ stripePublishableKey: "pk_test_mock",
+ },
+ source: "olitt",
+ }),
+ );
+ });
+ it("returns null when Olitt returns 404", async () => {
+ vi.stubEnv("OLITT_API_URL", "https://olitt.example.com");
+ vi.stubEnv("OLITT_API_TOKEN", "secret-token");
+
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
+ ok: false,
+ status: 404,
+ json: async () => null,
+ } as Response);
+
+ const config = await fetchTenantConfigFromOlitt("localhost:3000");
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ expect(config).toBeNull();
+ });
+
+ it("returns null when a mocked payload does not match the host", async () => {
+ vi.stubEnv("OLITT_API_URL", "https://olitt.example.com");
+ vi.stubEnv(
+ "OLITT_MOCK_RESPONSES",
+ JSON.stringify([
+ {
+ host: "other.localhost",
+ response: { store: { olittDomain: "other.localhost" } },
+ },
+ ]),
+ );
+
+ vi.spyOn(globalThis, "fetch").mockResolvedValue({
+ ok: false,
+ status: 404,
+ json: async () => null,
+ } as Response);
+
+ await expect(
+ fetchTenantConfigFromOlitt("shop.localhost:3001"),
+ ).resolves.toBeNull();
+ });
+});
diff --git a/src/lib/tenant/__tests__/surface.test.ts b/src/lib/tenant/__tests__/surface.test.ts
new file mode 100644
index 00000000..f6fd91eb
--- /dev/null
+++ b/src/lib/tenant/__tests__/surface.test.ts
@@ -0,0 +1,59 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import {
+ getTenantBrandName,
+ getTenantDescription,
+ getTenantLogoUrl,
+ getTenantNavigationLinks,
+ getTenantSiteUrl,
+ getTenantSocialLinks,
+ getTenantTwitterHandle,
+} from "../surface";
+
+describe("tenant surface helpers", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("reads surface values from tenant config", () => {
+ const config = {
+ tenantId: "tenant-1",
+ host: "shop.example.com",
+ storeName: "Brand Store",
+ storeDescription: "Brand description",
+ storeUrl: "https://shop.example.com",
+ defaultCountry: "us",
+ defaultLocale: "en",
+ spree: {
+ apiUrl: "https://spree.example.com",
+ publishableKey: "pub-key",
+ },
+ paymentKeys: {},
+ theme: {
+ logoUrl: "https://cdn.example.com/logo.svg",
+ },
+ seo: {
+ twitter: "brand",
+ socials: ["https://instagram.com/brand"],
+ },
+ navigation: {
+ links: [{ label: "Products", href: "/products" }],
+ },
+ raw: {},
+ source: "olitt",
+ fetchedAt: new Date().toISOString(),
+ } as never;
+
+ expect(getTenantBrandName(config)).toBe("Brand Store");
+ expect(getTenantDescription(config)).toBe("Brand description");
+ expect(getTenantSiteUrl(config)).toBe("https://shop.example.com");
+ expect(getTenantLogoUrl(config)).toBe("https://cdn.example.com/logo.svg");
+ expect(getTenantTwitterHandle(config)).toBe("brand");
+ expect(getTenantSocialLinks(config)).toEqual([
+ "https://instagram.com/brand",
+ ]);
+ expect(getTenantNavigationLinks(config)).toEqual([
+ { label: "Products", href: "/products" },
+ ]);
+ });
+});
diff --git a/src/lib/tenant/css-vars.ts b/src/lib/tenant/css-vars.ts
new file mode 100644
index 00000000..c5f6f23b
--- /dev/null
+++ b/src/lib/tenant/css-vars.ts
@@ -0,0 +1,105 @@
+import type { TenantConfig } from "./types";
+
+const SPACING_MAP = {
+ compact: { section: "3rem", component: "1rem" },
+ comfortable: { section: "5rem", component: "1.5rem" },
+ spacious: { section: "8rem", component: "2rem" },
+} as const;
+
+const RADIUS_MAP = {
+ none: "0px",
+ sm: "4px",
+ md: "8px",
+ lg: "16px",
+ full: "9999px",
+} as const;
+
+function getString(value: unknown): string | undefined {
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
+}
+
+function getThemeRecord(config: TenantConfig): Record {
+ return config.theme && typeof config.theme === "object"
+ ? (config.theme as Record)
+ : {};
+}
+
+function getNestedRecord(value: unknown): Record | undefined {
+ if (value && typeof value === "object" && !Array.isArray(value)) {
+ return value as Record;
+ }
+ return undefined;
+}
+
+function mergeRecords(
+ base: Record,
+ override?: Record,
+): Record {
+ if (!override) return base;
+
+ const result: Record = { ...base };
+ for (const [key, value] of Object.entries(override)) {
+ const baseValue = result[key];
+ if (getNestedRecord(baseValue) && getNestedRecord(value)) {
+ result[key] = mergeRecords(
+ getNestedRecord(baseValue) ?? {},
+ getNestedRecord(value),
+ );
+ continue;
+ }
+
+ result[key] = value;
+ }
+
+ return result;
+}
+
+export function resolveTenantThemeConfig(
+ config: TenantConfig,
+): Record {
+ const theme = getThemeRecord(config);
+ const raw = getNestedRecord(config.raw);
+ const design = getNestedRecord(raw?.design);
+ const designStyle = getNestedRecord(design?.style) ?? {};
+
+ return mergeRecords(designStyle, theme);
+}
+
+export function buildCssVars(config: TenantConfig): Record {
+ const theme = resolveTenantThemeConfig(config);
+ const branding = getNestedRecord(theme.branding);
+ const colors =
+ getNestedRecord(theme.colors) ?? getNestedRecord(branding?.colors) ?? {};
+ const fonts = getNestedRecord(theme.fonts) ?? {};
+
+ const spacing = getString(theme.spacing);
+ const radius = getString(theme.borderRadius);
+ const spacingPreset =
+ SPACING_MAP[(spacing as keyof typeof SPACING_MAP) ?? "comfortable"] ??
+ SPACING_MAP.comfortable;
+ const radiusPreset =
+ RADIUS_MAP[(radius as keyof typeof RADIUS_MAP) ?? "md"] ?? RADIUS_MAP.md;
+
+ return {
+ "--color-primary": getString(colors.primary) ?? "#16a34a",
+ "--color-secondary": getString(colors.secondary) ?? "#0ea5e9",
+ "--color-accent": getString(colors.accent) ?? "#f59e0b",
+ "--color-background": getString(colors.background) ?? "#ffffff",
+ "--color-surface": getString(colors.surface) ?? "#f9fafb",
+ "--color-text": getString(colors.text) ?? "#111827",
+ "--color-text-muted": getString(colors.textMuted) ?? "#6b7280",
+ "--color-border": getString(colors.border) ?? "#e5e7eb",
+ "--font-heading": `'${getString(fonts.heading) ?? "Inter"}', sans-serif`,
+ "--font-body": `'${getString(fonts.body) ?? getString(fonts.heading) ?? "Inter"}', sans-serif`,
+ "--font-heading-weight": String(getString(fonts.headingWeight) ?? 600),
+ "--radius": radiusPreset,
+ "--spacing-section": spacingPreset.section,
+ "--spacing-component": spacingPreset.component,
+ };
+}
+
+export function cssVarsToString(vars: Record): string {
+ return Object.entries(vars)
+ .map(([key, value]) => `${key}:${value}`)
+ .join(";");
+}
diff --git a/src/lib/tenant/index.ts b/src/lib/tenant/index.ts
new file mode 100644
index 00000000..17d4f105
--- /dev/null
+++ b/src/lib/tenant/index.ts
@@ -0,0 +1,4 @@
+export * from "./normalize";
+export * from "./olitt";
+export * from "./resolvers";
+export * from "./types";
diff --git a/src/lib/tenant/normalize.ts b/src/lib/tenant/normalize.ts
new file mode 100644
index 00000000..1712bd2d
--- /dev/null
+++ b/src/lib/tenant/normalize.ts
@@ -0,0 +1,226 @@
+import type {
+ PublicTenantConfig,
+ PublicTenantPaymentKeys,
+ TenantConfig,
+} from "./types";
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function toStringValue(value: unknown): string | undefined {
+ if (typeof value !== "string") return undefined;
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : undefined;
+}
+
+export interface NormalizeHostOptions {
+ preservePort?: boolean;
+}
+
+export function normalizeHost(
+ hostname: string | null | undefined,
+ options: NormalizeHostOptions = {},
+): string | null {
+ if (!hostname) return null;
+
+ const trimmed = hostname.trim();
+ if (!trimmed) return null;
+
+ const preservePort = options.preservePort ?? false;
+
+ try {
+ const url = trimmed.includes("://")
+ ? new URL(trimmed)
+ : new URL(`https://${trimmed}`);
+ const host = url.hostname.toLowerCase();
+ const port = preservePort && url.port ? `:${url.port}` : "";
+ return host ? `${host}${port}` : null;
+ } catch {
+ const withoutProtocol = trimmed.replace(/^https?:\/\//i, "");
+ const withoutPath = withoutProtocol.split("/")[0] ?? "";
+ const colonIndex = withoutPath.lastIndexOf(":");
+
+ if (colonIndex > -1) {
+ const hostnamePart = withoutPath.slice(0, colonIndex).toLowerCase();
+ const portPart = withoutPath.slice(colonIndex + 1).trim();
+ if (!hostnamePart) return null;
+ return preservePort && portPart
+ ? `${hostnamePart}:${portPart}`
+ : hostnamePart;
+ }
+
+ const normalized = withoutPath.toLowerCase();
+ return normalized || null;
+ }
+}
+
+function normalizeHostVariants(hostname: string | null | undefined): string[] {
+ const variants = [
+ normalizeHost(hostname, { preservePort: true }),
+ normalizeHost(hostname),
+ ].filter((value): value is string => Boolean(value));
+
+ return Array.from(new Set(variants));
+}
+
+function recordHostCandidates(record: Record): string[] {
+ return [record.olittDomain, record.customDomain]
+ .flatMap((value) => normalizeHostVariants(toStringValue(value)))
+ .filter((value): value is string => Boolean(value));
+}
+
+function recordMatchesHost(
+ record: Record,
+ host: string,
+): boolean {
+ const requestVariants = normalizeHostVariants(host);
+ const recordVariants = new Set(recordHostCandidates(record));
+
+ return requestVariants.some((variant) => recordVariants.has(variant));
+}
+
+export function findOlittStoreRecord(
+ payload: unknown,
+ host: string,
+): Record | null {
+ const normalizedHost =
+ normalizeHost(host, { preservePort: true }) ?? normalizeHost(host);
+ if (!normalizedHost) return null;
+
+ if (isRecord(payload)) {
+ const store = payload.store;
+ if (isRecord(store) && recordMatchesHost(store, normalizedHost)) {
+ return store;
+ }
+
+ if (recordMatchesHost(payload, normalizedHost)) {
+ return payload;
+ }
+ }
+
+ return null;
+}
+
+function pickString(
+ record: Record,
+ keys: string[],
+): string | undefined {
+ for (const key of keys) {
+ const value = toStringValue(record[key]);
+ if (value) return value;
+ }
+ return undefined;
+}
+
+function pickRecord(
+ record: Record,
+ keys: string[],
+): Record {
+ for (const key of keys) {
+ const value = record[key];
+ if (isRecord(value)) return value;
+ }
+ return {};
+}
+
+function collectStringMap(value: unknown): Record {
+ if (!isRecord(value)) return {};
+
+ const result: Record = {};
+ for (const [key, entry] of Object.entries(value)) {
+ const stringValue = toStringValue(entry);
+ if (stringValue) {
+ result[key] = stringValue;
+ }
+ }
+
+ return result;
+}
+
+function collectPaymentKeys(
+ record: Record,
+): Record {
+ const result: Record = {};
+
+ Object.assign(result, collectStringMap(record.paymentKeys));
+
+ for (const [key, value] of Object.entries(record.paymentKeys ?? {})) {
+ if (typeof value === "string" && value.trim()) {
+ result[key] = value.trim();
+ }
+ }
+
+ return result;
+}
+
+function collectPublicPaymentKeys(
+ config: TenantConfig,
+): PublicTenantPaymentKeys {
+ const stripePublishableKey =
+ config.paymentKeys.stripePublishableKey?.trim() || undefined;
+
+ return {
+ ...(stripePublishableKey ? { stripePublishableKey } : {}),
+ };
+}
+
+function getSpreeConfig(record: Record): {
+ apiUrl: string;
+ publishableKey: string;
+} {
+ const apiUrl = pickString(record, ["spreeApiUrl"]) ?? "";
+ const publishableKey = pickString(record, ["spreePublishableKey"]) ?? "";
+
+ return { apiUrl, publishableKey };
+}
+
+export function buildTenantConfigFromRecord(
+ record: Record,
+ host: string,
+): TenantConfig {
+ const normalizedHost =
+ normalizeHost(host, { preservePort: true }) ??
+ normalizeHost(host) ??
+ host.toLowerCase();
+ const tenantId = pickString(record, ["tenantId", "id"]) ?? normalizedHost;
+ const storeName = pickString(record, ["name"]) ?? "";
+ const storeDescription = pickString(record, ["description"]) ?? "";
+ const defaultCountry = (
+ pickString(record, ["defaultCountry"]) ?? ""
+ ).toLowerCase();
+ const defaultLocale = pickString(record, ["defaultLocale"]) ?? "";
+ const seo = pickRecord(record, ["seo"]);
+ const storeUrl =
+ pickString(record, ["storeUrl"]) ?? pickString(seo, ["siteUrl"]);
+ const theme = pickRecord(record, ["theme"]);
+ const navigation = pickRecord(record, ["navigation"]);
+
+ return {
+ tenantId,
+ host: normalizedHost,
+ storeName,
+ storeDescription,
+ ...(storeUrl ? { storeUrl } : {}),
+ defaultCountry,
+ defaultLocale,
+ spree: getSpreeConfig(record),
+ paymentKeys: collectPaymentKeys(record),
+ theme,
+ seo,
+ navigation,
+ raw: record,
+ source: "olitt",
+ fetchedAt: new Date().toISOString(),
+ };
+}
+
+export function toPublicTenantConfig(config: TenantConfig): PublicTenantConfig {
+ return {
+ storeName: config.storeName,
+ spree: config.spree,
+ paymentKeys: collectPublicPaymentKeys(config),
+ theme: config.theme,
+ navigation: config.navigation,
+ };
+}
diff --git a/src/lib/tenant/olitt.ts b/src/lib/tenant/olitt.ts
new file mode 100644
index 00000000..f155b614
--- /dev/null
+++ b/src/lib/tenant/olitt.ts
@@ -0,0 +1,245 @@
+"use server";
+
+import { cacheLife, cacheTag } from "next/cache";
+import {
+ buildTenantConfigFromRecord,
+ findOlittStoreRecord,
+ normalizeHost,
+} from "./normalize";
+import type { TenantConfig } from "./types";
+
+const DEFAULT_CACHE_TTL = "tenMinutes";
+const useTenantCache = process.env.NODE_ENV === "production";
+
+type OlittMockEntry =
+ | {
+ host?: string;
+ requestHost?: string;
+ aliases?: string[];
+ response?: unknown;
+ payload?: unknown;
+ body?: unknown;
+ data?: unknown;
+ }
+ | unknown;
+
+function unwrapMockPayload(entry: unknown): unknown {
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
+ return entry;
+ }
+
+ const record = entry as Record;
+ for (const key of ["response", "payload", "body", "data"]) {
+ if (key in record) {
+ return record[key];
+ }
+ }
+
+ return entry;
+}
+
+function parseMockHosts(entry: OlittMockEntry): string[] {
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
+ return [];
+ }
+
+ const record = entry as Record;
+ const hosts: string[] = [];
+
+ for (const key of ["host", "requestHost"]) {
+ const value = record[key];
+ if (typeof value === "string") hosts.push(value);
+ }
+
+ const aliases = record.aliases;
+ if (Array.isArray(aliases)) {
+ for (const alias of aliases) {
+ if (typeof alias === "string") hosts.push(alias);
+ }
+ }
+
+ return hosts
+ .map(
+ (value) =>
+ normalizeHost(value, { preservePort: true }) ?? normalizeHost(value),
+ )
+ .filter((value): value is string => Boolean(value));
+}
+
+function getMockedOlittPayload(host: string): unknown | null {
+ const rawValue = process.env.OLITT_MOCK_RESPONSES?.trim();
+ if (!rawValue) return null;
+
+ const normalizedHost =
+ normalizeHost(host, { preservePort: true }) ?? normalizeHost(host);
+ if (!normalizedHost) return null;
+
+ try {
+ const parsed: unknown = JSON.parse(rawValue);
+
+ if (Array.isArray(parsed)) {
+ for (const entry of parsed) {
+ const hosts = parseMockHosts(entry as OlittMockEntry);
+ if (hosts.includes(normalizedHost)) {
+ return unwrapMockPayload(entry);
+ }
+ }
+ return null;
+ }
+
+ if (!parsed || typeof parsed !== "object") {
+ return null;
+ }
+
+ const record = parsed as Record;
+ for (const [key, value] of Object.entries(record)) {
+ const normalizedKey =
+ normalizeHost(key, { preservePort: true }) ?? normalizeHost(key);
+ if (normalizedKey === normalizedHost) {
+ return unwrapMockPayload(value);
+ }
+ }
+
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+function getCanonicalRecordHost(
+ record: Record,
+ fallbackHost: string,
+): string {
+ for (const key of ["customDomain", "olittDomain"]) {
+ const value = record[key];
+ if (typeof value !== "string") continue;
+
+ const normalizedValue =
+ normalizeHost(value, { preservePort: true }) ?? normalizeHost(value);
+ if (normalizedValue) {
+ return normalizedValue;
+ }
+ }
+
+ return fallbackHost;
+}
+
+function getOlittApiUrl(): string | undefined {
+ return process.env.OLITT_API_URL?.trim() || undefined;
+}
+
+function getOlittLookupPath(): string {
+ return process.env.OLITT_LOOKUP_PATH?.trim() || "/api/stores/resolve";
+}
+
+function getOlittApiToken(): string | undefined {
+ return process.env.OLITT_API_TOKEN?.trim() || undefined;
+}
+
+function buildOlittLookupUrl(host: string): string | null {
+ const olittApiUrl = getOlittApiUrl();
+ if (!olittApiUrl) return null;
+
+ try {
+ const url = new URL(getOlittLookupPath(), olittApiUrl);
+ url.searchParams.set("domain", host);
+ return url.toString();
+ } catch {
+ return null;
+ }
+}
+
+export async function fetchTenantConfigFromOlitt(
+ host: string,
+): Promise {
+ const normalizedHost =
+ normalizeHost(host, { preservePort: true }) ?? normalizeHost(host);
+ if (!normalizedHost) return null;
+
+ const mockedPayload = getMockedOlittPayload(normalizedHost);
+ if (mockedPayload !== null) {
+ const mockedStoreRecord = findOlittStoreRecord(
+ mockedPayload,
+ normalizedHost,
+ );
+
+ if (!mockedStoreRecord) {
+ return null;
+ }
+
+ return buildTenantConfigFromRecord(
+ mockedStoreRecord,
+ getCanonicalRecordHost(mockedStoreRecord, normalizedHost),
+ );
+ }
+
+ const lookupUrl = buildOlittLookupUrl(normalizedHost);
+ if (!lookupUrl) {
+ throw new Error("Olitt API URL is not configured.");
+ }
+
+ const response = await fetch(lookupUrl, {
+ method: "GET",
+ headers: {
+ accept: "application/json",
+ ...(getOlittApiToken()
+ ? {
+ authorization: `Bearer ${getOlittApiToken()}`,
+ }
+ : {}),
+ "x-request-host": normalizedHost,
+ },
+ cache: "no-store",
+ });
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ return null;
+ }
+
+ throw new Error(
+ `Failed to load tenant config from Olitt for ${normalizedHost}: ${response.status}`,
+ );
+ }
+
+ const payload: unknown = await response.json().catch(() => null);
+ const storeRecord = findOlittStoreRecord(payload, normalizedHost);
+
+ if (!storeRecord) {
+ return null;
+ }
+
+ return buildTenantConfigFromRecord(
+ storeRecord,
+ getCanonicalRecordHost(storeRecord, normalizedHost),
+ );
+}
+
+async function cachedResolveTenantConfigByHost(
+ host: string,
+): Promise {
+ "use cache: remote";
+ cacheLife(DEFAULT_CACHE_TTL);
+ cacheTag("tenant-config", `tenant-config:${host}`);
+ return fetchTenantConfigFromOlitt(host);
+}
+
+export async function resolveTenantConfigByHost(
+ host: string,
+): Promise {
+ const normalizedHost =
+ normalizeHost(host, { preservePort: true }) ?? normalizeHost(host);
+ if (!normalizedHost) return null;
+
+ if (!useTenantCache) {
+ return fetchTenantConfigFromOlitt(normalizedHost);
+ }
+
+ return cachedResolveTenantConfigByHost(normalizedHost);
+}
+
+export async function getTenantConfigByHost(
+ host: string,
+): Promise {
+ return resolveTenantConfigByHost(host);
+}
diff --git a/src/lib/tenant/request.ts b/src/lib/tenant/request.ts
new file mode 100644
index 00000000..984e7bd0
--- /dev/null
+++ b/src/lib/tenant/request.ts
@@ -0,0 +1,47 @@
+import { headers } from "next/headers";
+import { getTenantConfigByHost } from "@/lib/tenant";
+import { normalizeHost } from "@/lib/tenant/normalize";
+
+const TRUST_PROXY_ENV_VALUES = ["TRUST_PROXY", "NEXT_TRUST_PROXY"] as const;
+
+function isTruthyEnvValue(value: string | undefined): boolean {
+ return value === "1" || value?.toLowerCase() === "true";
+}
+
+function isTrustedProxyEnabled(): boolean {
+ return TRUST_PROXY_ENV_VALUES.some((key) =>
+ isTruthyEnvValue(process.env[key]),
+ );
+}
+
+function normalizeHeaderHost(value: string | null): string | null {
+ if (!value) return null;
+
+ for (const entry of value.split(",")) {
+ const normalized = normalizeHost(entry, { preservePort: true });
+ if (normalized) return normalized;
+ }
+
+ return null;
+}
+
+export function getRequestHost(requestHeaders: Headers): string | null {
+ const host = normalizeHeaderHost(requestHeaders.get("host"));
+ if (!isTrustedProxyEnabled()) {
+ return host;
+ }
+
+ const forwardedHost = normalizeHeaderHost(
+ requestHeaders.get("x-forwarded-host"),
+ );
+
+ return forwardedHost ?? host;
+}
+
+export async function getTenantConfigFromRequest() {
+ const requestHeaders = await headers();
+ const host = getRequestHost(requestHeaders);
+
+ if (!host) return null;
+ return getTenantConfigByHost(host);
+}
diff --git a/src/lib/tenant/resolvers.ts b/src/lib/tenant/resolvers.ts
new file mode 100644
index 00000000..7a50e24c
--- /dev/null
+++ b/src/lib/tenant/resolvers.ts
@@ -0,0 +1,281 @@
+import type { DynamicPageSectionConfig } from "@/lib/page-builder";
+import {
+ getTenantBrandName,
+ getTenantDescription,
+ getTenantLogoUrl,
+ getTenantNavigationLinks,
+ type TenantLink,
+} from "./surface";
+import type { TenantSurfaceConfig } from "./types";
+
+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 getArray(value: unknown): unknown[] {
+ return Array.isArray(value) ? value : [];
+}
+
+function getLink(value: unknown): TenantLink | null {
+ const record = getRecord(value);
+ if (!record) return null;
+
+ const label = getString(record.label);
+ const href = getString(record.href);
+ if (!label || !href) return null;
+
+ return { label, href };
+}
+
+function getLinks(value: unknown): TenantLink[] {
+ return getArray(value)
+ .map((entry) => getLink(entry))
+ .filter((entry): entry is TenantLink => Boolean(entry));
+}
+
+function getFirstNonEmptyLinks(...values: unknown[]): TenantLink[] {
+ for (const value of values) {
+ const links = getLinks(value);
+ if (links.length > 0) {
+ return links;
+ }
+ }
+
+ return [];
+}
+
+function isDynamicPageSection(
+ value: unknown,
+): value is DynamicPageSectionConfig {
+ const record = getRecord(value);
+ return Boolean(record && typeof record.type === "string");
+}
+
+function getSections(value: unknown): DynamicPageSectionConfig[] {
+ return getArray(value).filter(isDynamicPageSection);
+}
+
+function getRawConfig(
+ config?: TenantSurfaceConfig | null,
+): Record | undefined {
+ return getRecord(config?.raw);
+}
+
+function getDesignConfig(
+ config?: TenantSurfaceConfig | null,
+): Record | undefined {
+ return getRecord(getRawConfig(config)?.design);
+}
+
+function getLayoutConfig(
+ config?: TenantSurfaceConfig | null,
+): Record | undefined {
+ return getRecord(getDesignConfig(config)?.layout);
+}
+
+export interface TenantBrandingConfig {
+ name?: string;
+ description?: string;
+ logoUrl?: string;
+}
+
+export interface TenantNavigationConfig {
+ headerLinks: TenantLink[];
+ footerLinks: TenantLink[];
+ checkoutLinks: TenantLink[];
+}
+
+export interface TenantFooterConfig {
+ description?: string;
+ resourceLinks: TenantLink[];
+ shopLinks: TenantLink[];
+ accountLinks: TenantLink[];
+ policyLinks: TenantLink[];
+ showPolicies: boolean;
+}
+
+export interface FixedPageSlotGroup {
+ beforeMain: DynamicPageSectionConfig[];
+ afterMain: DynamicPageSectionConfig[];
+}
+
+export interface TenantFixedPageSlotsConfig {
+ productPage: FixedPageSlotGroup;
+ checkoutPage: FixedPageSlotGroup;
+}
+
+export function resolveTenantBranding(
+ config?: TenantSurfaceConfig | null,
+ defaults: TenantBrandingConfig = {},
+): TenantBrandingConfig {
+ const raw = getRawConfig(config);
+ const layoutBranding = getRecord(getLayoutConfig(config)?.branding);
+ const designBranding = getRecord(getDesignConfig(config)?.branding);
+ const branding = getRecord(raw?.branding);
+
+ return {
+ name:
+ getString(layoutBranding?.name) ||
+ getString(designBranding?.name) ||
+ getString(branding?.name) ||
+ getTenantBrandName(config) ||
+ defaults.name,
+ description:
+ getString(layoutBranding?.description) ||
+ getString(designBranding?.description) ||
+ getString(branding?.description) ||
+ getTenantDescription(config) ||
+ defaults.description,
+ logoUrl:
+ getString(layoutBranding?.logoUrl) ||
+ getString(layoutBranding?.logo) ||
+ getString(designBranding?.logoUrl) ||
+ getString(designBranding?.logo) ||
+ getString(branding?.logoUrl) ||
+ getString(branding?.logo) ||
+ getTenantLogoUrl(config) ||
+ defaults.logoUrl,
+ };
+}
+
+export function resolveTenantNavigation(
+ config?: TenantSurfaceConfig | null,
+ defaults: Partial = {},
+): TenantNavigationConfig {
+ const raw = getRawConfig(config);
+ const layoutNavigation = getRecord(getLayoutConfig(config)?.navigation);
+ const navigation =
+ getRecord(raw?.navigation) ?? getRecord(config?.navigation);
+
+ const genericLinks = getFirstNonEmptyLinks(
+ layoutNavigation?.links,
+ navigation?.links,
+ getTenantNavigationLinks(config),
+ );
+
+ const headerLinks = getFirstNonEmptyLinks(
+ layoutNavigation?.headerLinks,
+ navigation?.headerLinks,
+ genericLinks,
+ defaults.headerLinks,
+ );
+
+ const footerLinks = getFirstNonEmptyLinks(
+ layoutNavigation?.footerLinks,
+ navigation?.footerLinks,
+ genericLinks,
+ defaults.footerLinks,
+ );
+
+ const checkoutLinks = getFirstNonEmptyLinks(
+ layoutNavigation?.checkoutLinks,
+ navigation?.checkoutLinks,
+ genericLinks,
+ defaults.checkoutLinks,
+ );
+
+ return { headerLinks, footerLinks, checkoutLinks };
+}
+
+export function resolveTenantFooter(
+ config?: TenantSurfaceConfig | null,
+ defaults: Partial = {},
+): TenantFooterConfig {
+ const raw = getRawConfig(config);
+ const layoutFooter = getRecord(getLayoutConfig(config)?.footer);
+ const footer = getRecord(raw?.footer);
+
+ return {
+ description:
+ getString(layoutFooter?.description) ||
+ getString(footer?.description) ||
+ getTenantDescription(config) ||
+ defaults.description,
+ resourceLinks:
+ getLinks(layoutFooter?.resourceLinks).length > 0
+ ? getLinks(layoutFooter?.resourceLinks)
+ : getLinks(layoutFooter?.externalLinks).length > 0
+ ? getLinks(layoutFooter?.externalLinks)
+ : getLinks(footer?.resourceLinks).length > 0
+ ? getLinks(footer?.resourceLinks)
+ : getLinks(footer?.externalLinks).length > 0
+ ? getLinks(footer?.externalLinks)
+ : (defaults.resourceLinks ?? []),
+ shopLinks:
+ getLinks(layoutFooter?.shopLinks).length > 0
+ ? getLinks(layoutFooter?.shopLinks)
+ : getLinks(footer?.shopLinks).length > 0
+ ? getLinks(footer?.shopLinks)
+ : (defaults.shopLinks ?? []),
+ accountLinks:
+ getLinks(layoutFooter?.accountLinks).length > 0
+ ? getLinks(layoutFooter?.accountLinks)
+ : getLinks(footer?.accountLinks).length > 0
+ ? getLinks(footer?.accountLinks)
+ : (defaults.accountLinks ?? []),
+ policyLinks:
+ getLinks(layoutFooter?.policyLinks).length > 0
+ ? getLinks(layoutFooter?.policyLinks)
+ : getLinks(footer?.policyLinks).length > 0
+ ? getLinks(footer?.policyLinks)
+ : (defaults.policyLinks ?? []),
+ showPolicies:
+ getBoolean(layoutFooter?.showPolicies) ??
+ getBoolean(footer?.showPolicies) ??
+ defaults.showPolicies ??
+ true,
+ };
+}
+
+export function resolveTenantFixedPageSlots(
+ config?: TenantSurfaceConfig | null,
+ defaults: Partial = {},
+): TenantFixedPageSlotsConfig {
+ const raw = getRawConfig(config);
+ const layout = getLayoutConfig(config);
+ const pageSlots =
+ getRecord(layout?.pageSlots) ||
+ getRecord(layout?.fixedPageSlots) ||
+ getRecord(raw?.pageSlots) ||
+ getRecord(raw?.fixedPageSlots);
+
+ const productPage =
+ getRecord(pageSlots?.productPage) || getRecord(pageSlots?.product);
+ const checkoutPage =
+ getRecord(pageSlots?.checkoutPage) || getRecord(pageSlots?.checkout);
+
+ return {
+ productPage: {
+ beforeMain:
+ getSections(productPage?.beforeMain).length > 0
+ ? getSections(productPage?.beforeMain)
+ : (defaults.productPage?.beforeMain ?? []),
+ afterMain:
+ getSections(productPage?.afterMain).length > 0
+ ? getSections(productPage?.afterMain)
+ : (defaults.productPage?.afterMain ?? []),
+ },
+ checkoutPage: {
+ beforeMain:
+ getSections(checkoutPage?.beforeMain).length > 0
+ ? getSections(checkoutPage?.beforeMain)
+ : (defaults.checkoutPage?.beforeMain ?? []),
+ afterMain:
+ getSections(checkoutPage?.afterMain).length > 0
+ ? getSections(checkoutPage?.afterMain)
+ : (defaults.checkoutPage?.afterMain ?? []),
+ },
+ };
+}
diff --git a/src/lib/tenant/surface.ts b/src/lib/tenant/surface.ts
new file mode 100644
index 00000000..bfe0eed2
--- /dev/null
+++ b/src/lib/tenant/surface.ts
@@ -0,0 +1,127 @@
+import type { TenantSurfaceConfig } from "./types";
+export interface TenantLink {
+ label: string;
+ href: 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 getArray(value: unknown): unknown[] {
+ return Array.isArray(value) ? value : [];
+}
+
+export function getTenantBrandName(
+ config?: TenantSurfaceConfig | null,
+): string | undefined {
+ const branding = getRecord(config?.raw?.branding);
+
+ return (
+ config?.storeName ||
+ getString(branding?.name) ||
+ getString(branding?.tagline) ||
+ undefined
+ );
+}
+
+export function getTenantDescription(
+ config?: TenantSurfaceConfig | null,
+): string | undefined {
+ const branding = getRecord(config?.raw?.branding);
+
+ return (
+ config?.storeDescription ||
+ getString(getRecord(config?.seo)?.description) ||
+ getString(branding?.tagline) ||
+ undefined
+ );
+}
+
+export function getTenantSiteUrl(
+ config?: TenantSurfaceConfig | null,
+): string | undefined {
+ return (
+ config?.storeUrl ||
+ getString(getRecord(config?.seo)?.siteUrl) ||
+ getString(getRecord(config?.raw)?.storeUrl) ||
+ undefined
+ );
+}
+
+export function getTenantLogoUrl(
+ config?: TenantSurfaceConfig | null,
+): string | undefined {
+ const branding = getRecord(config?.raw?.branding);
+ const theme = getRecord(config?.theme);
+ const seo = getRecord(config?.seo);
+ const raw = getRecord(config?.raw);
+
+ return (
+ getString(branding?.logo) ||
+ getString(theme?.logoUrl) ||
+ getString(theme?.logo) ||
+ getString(seo?.logoUrl) ||
+ getString(seo?.logo) ||
+ getString(raw?.logoUrl) ||
+ getString(raw?.logo) ||
+ undefined
+ );
+}
+
+export function getTenantTwitterHandle(
+ config?: TenantSurfaceConfig | null,
+): string | undefined {
+ const seo = getRecord(config?.seo);
+ const raw = getRecord(config?.raw);
+
+ return (
+ getString(seo?.twitter) ||
+ getString(raw?.twitter) ||
+ getString(raw?.storeTwitter) ||
+ undefined
+ );
+}
+
+export function getTenantSocialLinks(
+ config?: TenantSurfaceConfig | null,
+): string[] {
+ const branding = getRecord(config?.raw?.branding);
+ const seo = getRecord(config?.seo);
+ const raw = getRecord(config?.raw);
+
+ const socials = [
+ ...getArray(branding?.social_links),
+ ...getArray(seo?.socials),
+ ...getArray(raw?.socials),
+ ]
+ .map((value) => getString(value))
+ .filter((value): value is string => Boolean(value));
+
+ return socials;
+}
+
+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));
+}
diff --git a/src/lib/tenant/types.ts b/src/lib/tenant/types.ts
new file mode 100644
index 00000000..ff425b90
--- /dev/null
+++ b/src/lib/tenant/types.ts
@@ -0,0 +1,44 @@
+export interface TenantSpreeConfig {
+ apiUrl: string;
+ publishableKey: string;
+}
+
+export interface TenantSurfaceConfig {
+ storeName?: string;
+ storeDescription?: string;
+ storeUrl?: string;
+ theme?: Record;
+ seo?: Record;
+ navigation?: Record;
+ raw?: Record;
+}
+
+export interface PublicTenantPaymentKeys {
+ stripePublishableKey?: string;
+}
+
+export interface PublicTenantConfig extends TenantSurfaceConfig {
+ storeName: string;
+ spree: TenantSpreeConfig;
+ paymentKeys: PublicTenantPaymentKeys;
+ theme: Record;
+ navigation: Record;
+}
+
+export interface TenantConfig extends TenantSurfaceConfig {
+ tenantId: string;
+ host: string;
+ storeName: string;
+ storeDescription: string;
+ storeUrl?: string;
+ defaultCountry: string;
+ defaultLocale: string;
+ spree: TenantSpreeConfig;
+ paymentKeys: Record;
+ theme: Record;
+ seo: Record;
+ navigation: Record;
+ raw: Record;
+ source: "olitt";
+ fetchedAt: string;
+}