From 59ced21d2ef57a73bb5af2164e867ac38e0a64af Mon Sep 17 00:00:00 2001 From: kipsang Date: Sat, 9 May 2026 11:26:12 +0300 Subject: [PATCH 1/7] feat: enhance tenant configuration and surface helpers - Updated spree index to export additional configuration functions. - Refactored locale management to resolve spree configuration asynchronously. - Changed default store name to "Olitt Store" in store utility. - Added comprehensive tests for Olitt tenant configuration helpers. - Introduced surface helpers for tenant branding, navigation, and footer configurations. - Implemented CSS variable resolution for tenant themes. - Created new tenant normalization and request handling utilities. - Established a structured approach for fetching and resolving tenant configurations from Olitt API. --- .dockerignore | 14 + .env.example | 8 +- .github/workflows/docker-publish.yml | 58 ++++ Dockerfile | 38 +++ next.config.ts | 2 + package-lock.json | 25 ++ .../(checkout)/checkout/[id]/page.tsx | 50 +++- .../[country]/[locale]/(checkout)/layout.tsx | 104 +++++-- .../[locale]/(storefront)/layout.tsx | 10 + .../[country]/[locale]/(storefront)/page.tsx | 46 ++- .../(storefront)/pages/[...slug]/page.tsx | 69 +++++ .../(storefront)/policies/[slug]/page.tsx | 12 +- .../(storefront)/products/[slug]/page.tsx | 29 ++ src/app/[country]/[locale]/layout.tsx | 118 ++++++-- src/app/layout.tsx | 15 +- .../home/FeaturedProductsSection.tsx | 79 +++-- src/components/home/FeaturesSection.tsx | 82 ++++++ src/components/home/HeroSection.tsx | 272 ++++++++++++++--- src/components/layout/Footer.tsx | 174 +++++------ src/components/layout/Header.tsx | 37 ++- .../marketing/OlittFallbackPage.tsx | 222 ++++++++++++++ .../page-builder/DynamicPageRenderer.tsx | 29 ++ .../page-builder/ImageBannerSection.tsx | 95 ++++++ .../page-builder/PageSectionsRenderer.tsx | 70 +++++ .../page-builder/RichTextSection.tsx | 68 +++++ src/contexts/CartContext.tsx | 22 ++ src/contexts/TenantContext.tsx | 46 +++ src/contexts/__tests__/TenantContext.test.tsx | 57 ++++ src/lib/data/categories.ts | 113 ++++++- src/lib/data/countries.ts | 26 +- src/lib/data/markets.ts | 65 ++++- src/lib/data/products.ts | 121 +++++++- src/lib/homepage/default-homepage.json | 103 +++++++ src/lib/homepage/index.ts | 164 +++++++++++ src/lib/homepage/types.ts | 83 ++++++ src/lib/metadata/category.ts | 19 +- src/lib/metadata/home.ts | 19 +- src/lib/metadata/product.ts | 9 +- src/lib/metadata/products.ts | 9 +- src/lib/metadata/store.ts | 22 +- src/lib/page-builder/default-pages.json | 107 +++++++ src/lib/page-builder/index.ts | 238 +++++++++++++++ src/lib/page-builder/types.ts | 55 ++++ src/lib/seo.ts | 31 +- src/lib/spree/config.test.ts | 100 +++++++ src/lib/spree/config.ts | 142 +++++++-- src/lib/spree/index.ts | 9 +- src/lib/spree/locale.ts | 4 +- src/lib/store.ts | 2 +- src/lib/tenant/__tests__/olitt.test.ts | 213 ++++++++++++++ src/lib/tenant/__tests__/surface.test.ts | 59 ++++ src/lib/tenant/css-vars.ts | 107 +++++++ src/lib/tenant/index.ts | 4 + src/lib/tenant/normalize.ts | 201 +++++++++++++ src/lib/tenant/olitt.ts | 245 ++++++++++++++++ src/lib/tenant/request.ts | 13 + src/lib/tenant/resolvers.ts | 275 ++++++++++++++++++ src/lib/tenant/surface.ts | 125 ++++++++ src/lib/tenant/types.ts | 22 ++ 59 files changed, 4249 insertions(+), 307 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker-publish.yml create mode 100644 Dockerfile create mode 100644 src/app/[country]/[locale]/(storefront)/pages/[...slug]/page.tsx create mode 100644 src/components/home/FeaturesSection.tsx create mode 100644 src/components/marketing/OlittFallbackPage.tsx create mode 100644 src/components/page-builder/DynamicPageRenderer.tsx create mode 100644 src/components/page-builder/ImageBannerSection.tsx create mode 100644 src/components/page-builder/PageSectionsRenderer.tsx create mode 100644 src/components/page-builder/RichTextSection.tsx create mode 100644 src/contexts/TenantContext.tsx create mode 100644 src/contexts/__tests__/TenantContext.test.tsx create mode 100644 src/lib/homepage/default-homepage.json create mode 100644 src/lib/homepage/index.ts create mode 100644 src/lib/homepage/types.ts create mode 100644 src/lib/page-builder/default-pages.json create mode 100644 src/lib/page-builder/index.ts create mode 100644 src/lib/page-builder/types.ts create mode 100644 src/lib/spree/config.test.ts create mode 100644 src/lib/tenant/__tests__/olitt.test.ts create mode 100644 src/lib/tenant/__tests__/surface.test.ts create mode 100644 src/lib/tenant/css-vars.ts create mode 100644 src/lib/tenant/index.ts create mode 100644 src/lib/tenant/normalize.ts create mode 100644 src/lib/tenant/olitt.ts create mode 100644 src/lib/tenant/request.ts create mode 100644 src/lib/tenant/resolvers.ts create mode 100644 src/lib/tenant/surface.ts create mode 100644 src/lib/tenant/types.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..8c1c93bf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.github +.next +node_modules +npm-debug.log* +.dockerignore +.env +.env.* +!.env.example +!.env.local.example +coverage +dist +tmp +.DS_Store \ No newline at end of file diff --git a/.env.example b/.env.example index 577a51ef..485bf618 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ # Spree API Configuration (server-side only - not exposed to browser) -SPREE_API_URL=http://localhost:3000 -SPREE_PUBLISHABLE_KEY=your_publishable_api_key +# SPREE_API_URL=http://localhost:3000 +# SPREE_PUBLISHABLE_KEY=your_publishable_api_key + +# Olitt tenant config API (host-based store config lookup) +OLITT_API_URL=https://api.olitt.example.com +OLITT_LOOKUP_PATH=api/shop/configuration # Store defaults (should match your Spree store settings) # These are used by the middleware for initial redirects before API data is loaded diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..cf5870a4 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,58 @@ +name: Docker Publish + +on: + push: + branches: + - main + - dev + workflow_dispatch: + +concurrency: + group: docker-publish-${{ github.ref }} + cancel-in-progress: true + +env: + IMAGE_NAME: storefront + +jobs: + docker: + name: Build and Push Docker Image + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=prod,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=prod-${{ github.sha }},enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }} + type=raw,value=dev-${{ github.sha }},enable=${{ github.ref == 'refs/heads/dev' }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..f541dc29 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM node:20-bookworm-slim AS base + +ENV NEXT_TELEMETRY_DISABLED=1 + +FROM base AS deps +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +FROM base AS builder +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +RUN npm run build + +FROM node:20-bookworm-slim AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV HOSTNAME=0.0.0.0 +ENV PORT=3001 + +RUN groupadd --system --gid 1001 nodejs \ + && useradd --system --uid 1001 --gid nodejs nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3001 + +CMD ["node", "server.js"] \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index 693831bc..ba0b4c03 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,6 +6,7 @@ const withNextIntl = createNextIntlPlugin(); const nextConfig: NextConfig = { allowedDevOrigins: ["shop.lvh.me", "*.trycloudflare.com"], + output: "standalone", env: { NEXT_PUBLIC_SENTRY_DSN: process.env.SENTRY_DSN || "", }, @@ -22,6 +23,7 @@ const nextConfig: NextConfig = { root: __dirname, }, cacheComponents: true, + // cacheComponents: process.env.NODE_ENV === "production", cacheLife: { tenMinutes: { stale: 300, // 5 minutes client stale window diff --git a/package-lock.json b/package-lock.json index 29be848b..c39a9816 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1114,6 +1114,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1752,6 +1753,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1774,6 +1776,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1796,6 +1799,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1812,6 +1816,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1828,6 +1833,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1844,6 +1850,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1860,6 +1867,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1876,6 +1884,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1892,6 +1901,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1908,6 +1918,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1924,6 +1935,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1940,6 +1952,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1956,6 +1969,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1978,6 +1992,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2000,6 +2015,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2022,6 +2038,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2044,6 +2061,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2066,6 +2084,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2088,6 +2107,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2110,6 +2130,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2132,6 +2153,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -2151,6 +2173,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2170,6 +2193,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2189,6 +2213,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ diff --git a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx index 8ba5908e..a7243d97 100644 --- a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx +++ b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx @@ -2,11 +2,18 @@ import type { Address, Cart, Country } from "@spree/sdk"; import { redirect } from "next/navigation"; import { connection } from "next/server"; import { Suspense } from "react"; +import { PageSectionsRenderer } from "@/components/page-builder/PageSectionsRenderer"; import { getAddresses } from "@/lib/data/addresses"; import { getCheckoutOrder } from "@/lib/data/checkout"; import { isAuthenticated as checkAuth } from "@/lib/data/cookies"; import { getCountry } from "@/lib/data/countries"; -import { getMarketCountries, resolveMarket } from "@/lib/data/markets"; +import { + getMarketCountries, + resolveCurrency, + resolveMarket, +} from "@/lib/data/markets"; +import { resolveTenantFixedPageSlots } from "@/lib/tenant"; +import { getTenantConfigFromRequest } from "@/lib/tenant/request"; import { CheckoutPageContent } from "./CheckoutPageContent"; @@ -28,7 +35,13 @@ interface CheckoutPageProps { async function CheckoutDataLoader({ params }: CheckoutPageProps) { await connection(); - const { id: cartId, country: urlCountry } = await params; + const { id: cartId, country: urlCountry, locale } = await params; + const basePath = `/${urlCountry}/${locale}`; + const [tenantConfig, currency] = await Promise.all([ + getTenantConfigFromRequest(), + resolveCurrency(urlCountry), + ]); + const pageSlots = resolveTenantFixedPageSlots(tenantConfig); // Check auth first so we can skip address fetch for guests const authStatus = await checkAuth(); @@ -42,7 +55,6 @@ async function CheckoutDataLoader({ params }: CheckoutPageProps) { // Redirect to order-placed if already complete if (cartData?.current_step === "complete") { - const basePath = `/${urlCountry}/en`; redirect(`${basePath}/order-placed/${cartId}`); } @@ -69,11 +81,33 @@ async function CheckoutDataLoader({ params }: CheckoutPageProps) { : null; return ( - + <> + {pageSlots.checkoutPage.beforeMain.length > 0 && ( + + )} + + {pageSlots.checkoutPage.afterMain.length > 0 && ( + + )} + ); } diff --git a/src/app/[country]/[locale]/(checkout)/layout.tsx b/src/app/[country]/[locale]/(checkout)/layout.tsx index f9b85ab0..e727b428 100644 --- a/src/app/[country]/[locale]/(checkout)/layout.tsx +++ b/src/app/[country]/[locale]/(checkout)/layout.tsx @@ -7,37 +7,57 @@ import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { CheckoutProvider, CheckoutSummary } from "@/contexts/CheckoutContext"; +import { useTenantConfig } from "@/contexts/TenantContext"; import { POLICY_LINKS } from "@/lib/constants/policies"; -import { getStoreName } from "@/lib/store"; +import { + resolveTenantBranding, + resolveTenantFooter, + resolveTenantNavigation, +} from "@/lib/tenant"; import { extractBasePath } from "@/lib/utils/path"; -const storeName = getStoreName(); - function CheckoutHeader() { const pathname = usePathname(); const basePath = extractBasePath(pathname); const t = useTranslations("checkoutLayout"); + const tenantConfig = useTenantConfig(); + const branding = resolveTenantBranding(tenantConfig, { + name: tenantConfig.storeName ?? "Store", + logoUrl: "/spree.png", + }); + const navigation = resolveTenantNavigation(tenantConfig); return (
{storeName} - -
); } @@ -47,22 +67,37 @@ function CheckoutFooter() { const basePath = extractBasePath(pathname); const t = useTranslations("checkoutLayout"); const tp = useTranslations("policies"); + const tenantConfig = useTenantConfig(); + const branding = resolveTenantBranding(tenantConfig, { + name: tenantConfig.storeName ?? "Store", + }); + const footerConfig = resolveTenantFooter(tenantConfig, { + policyLinks: POLICY_LINKS.map((policy) => ({ + label: tp(policy.nameKey), + href: `${basePath}/policies/${policy.slug}`, + })), + showPolicies: true, + }); return (

- {t("allRightsReserved", { year: new Date().getFullYear(), storeName })} + {t("allRightsReserved", { + year: new Date().getFullYear(), + storeName: branding.name ?? tenantConfig.storeName ?? "Store", + })}

- {POLICY_LINKS.map((policy) => ( - - {tp(policy.nameKey)} - - ))} + {footerConfig.showPolicies && + footerConfig.policyLinks.map((policy) => ( + + {policy.label} + + ))}
); } @@ -103,9 +138,18 @@ interface CheckoutLayoutProps { function CheckoutLayoutContent({ children }: CheckoutLayoutProps) { return ( -
+
{/* Mobile header */} -
+
@@ -131,7 +175,13 @@ function CheckoutLayoutContent({ children }: CheckoutLayoutProps) {
{/* Desktop summary sidebar — Shopify: light gray bg with left border */} -
+
diff --git a/src/app/[country]/[locale]/(storefront)/layout.tsx b/src/app/[country]/[locale]/(storefront)/layout.tsx index 1ae28ecd..4310874a 100644 --- a/src/app/[country]/[locale]/(storefront)/layout.tsx +++ b/src/app/[country]/[locale]/(storefront)/layout.tsx @@ -1,8 +1,10 @@ import type { Category } from "@spree/sdk"; +import { headers } from "next/headers"; import Link from "next/link"; import { Footer } from "@/components/layout/Footer"; import { Header } from "@/components/layout/Header"; import { getCategories } from "@/lib/data/categories"; +import { getTenantConfigByHost } from "@/lib/tenant"; interface StorefrontLayoutProps { children: React.ReactNode; @@ -38,6 +40,12 @@ export default async function StorefrontLayout({ }: StorefrontLayoutProps) { const { country, locale } = await params; const basePath = `/${country}/${locale}`; + const requestHeaders = await headers(); + const tenantHost = + requestHeaders.get("x-forwarded-host") ?? + requestHeaders.get("host") ?? + "localhost"; + const tenantConfig = await getTenantConfigByHost(tenantHost); const rootCategories = await getCategories({ depth_eq: 0, @@ -55,6 +63,7 @@ export default async function StorefrontLayout({ rootCategories={rootCategories} basePath={basePath} locale={locale as Locale} + tenantConfig={tenantConfig} /> {rootCategories.length > 0 && (
); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4fd6525e..c969aa1b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,11 +3,13 @@ import { Analytics } from "@vercel/analytics/next"; import { SpeedInsights } from "@vercel/speed-insights/next"; import type { Metadata } from "next"; import { Geist } from "next/font/google"; -import "./globals.css"; import { Suspense } from "react"; -import { CartProvider } from "@/contexts/CartContext"; + +import { CartProvider, CartProviderFallback } from "@/contexts/CartContext"; import { getStoreDescription, getStoreName } from "@/lib/store"; +import "./globals.css"; + const gtmId = process.env.GTM_ID; const spreeApiOrigin = (() => { try { @@ -35,13 +37,13 @@ export const metadata: Metadata = { description: getStoreDescription(), }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( - + {spreeApiOrigin && ( <> @@ -53,8 +55,11 @@ export default function RootLayout({ {gtmId && } - + {children}} + > {children} diff --git a/src/components/home/FeaturedProductsSection.tsx b/src/components/home/FeaturedProductsSection.tsx index 5a6101fe..95408d71 100644 --- a/src/components/home/FeaturedProductsSection.tsx +++ b/src/components/home/FeaturedProductsSection.tsx @@ -1,9 +1,10 @@ import Link from "next/link"; -import { getTranslations } from "next-intl/server"; +import type { CSSProperties } from "react"; import { Suspense } from "react"; import { FeaturedProducts } from "@/components/products/FeaturedProducts"; import { ProductCardSkeleton } from "@/components/products/ProductCardSkeleton"; import { Button } from "@/components/ui/button"; +import type { HomepageFeaturedProductsSectionConfig } from "@/lib/homepage"; function CarouselSkeleton() { return ( @@ -20,6 +21,7 @@ interface FeaturedProductsSectionProps { locale: string; country: string; currency?: string; + section: HomepageFeaturedProductsSectionConfig; } export async function FeaturedProductsSection({ @@ -27,30 +29,65 @@ export async function FeaturedProductsSection({ locale, country, currency, + section, }: FeaturedProductsSectionProps) { - const t = await getTranslations({ - locale: locale as Locale, - namespace: "home", - }); + const theme = section.theme ?? {}; + const sectionStyle: CSSProperties = { + backgroundColor: theme.background, + color: theme.foreground, + }; + const mutedTextColor = theme.mutedForeground ?? "#64748b"; + const borderColor = theme.borderColor ?? "#cbd5e1"; + + const resolveHref = (href: string) => { + if (/^https?:\/\//.test(href)) return href; + if (href.startsWith(basePath)) return href; + if (href === "/") return basePath; + return href.startsWith("/") ? `${basePath}${href}` : `${basePath}/${href}`; + }; return ( -
-
-

- {t("featuredProducts")} -

- +
+
+
+
+

+ {section.title} +

+ {section.description ? ( +

+ {section.description} +

+ ) : null} +
+ {section.cta ? ( + + ) : null} +
+
+ }> + + +
- }> - -
); } diff --git a/src/components/home/FeaturesSection.tsx b/src/components/home/FeaturesSection.tsx new file mode 100644 index 00000000..28fe466a --- /dev/null +++ b/src/components/home/FeaturesSection.tsx @@ -0,0 +1,82 @@ +import { ShieldCheck, ShoppingBag, Sparkles, Truck } from "lucide-react"; +import type { CSSProperties } from "react"; +import type { HomepageFeaturesSectionConfig } from "@/lib/homepage"; + +interface FeaturesSectionProps { + section: HomepageFeaturesSectionConfig; +} + +function getFeatureIcon(iconName?: string) { + switch (iconName) { + case "truck": + return ; + case "shield": + return ; + case "shopping-bag": + return ; + case "sparkles": + default: + return ; + } +} + +export async function FeaturesSection({ section }: FeaturesSectionProps) { + const theme = section.theme ?? {}; + const sectionStyle: CSSProperties = { + backgroundColor: theme.background, + color: theme.foreground, + }; + const mutedTextColor = theme.mutedForeground ?? "#6b7280"; + const cardBackground = theme.cardBackground ?? "#ffffff"; + const borderColor = theme.borderColor ?? "#e5e7eb"; + const accentColor = theme.accent ?? "#2563eb"; + + return ( +
+
+ {section.title ? ( +
+

+ {section.title} +

+ {section.description ? ( +

+ {section.description} +

+ ) : null} +
+ ) : null} + +
+ {section.items.map((feature, id) => ( +
+
+ {getFeatureIcon(feature.icon)} +
+

{feature.title}

+

+ {feature.description} +

+
+ ))} +
+
+
+ ); +} diff --git a/src/components/home/HeroSection.tsx b/src/components/home/HeroSection.tsx index 0e34f6f7..251a0816 100644 --- a/src/components/home/HeroSection.tsx +++ b/src/components/home/HeroSection.tsx @@ -1,54 +1,246 @@ +import { ArrowRight, Play } from "lucide-react"; import Link from "next/link"; -import { getTranslations } from "next-intl/server"; +import type { CSSProperties } from "react"; import { Button } from "@/components/ui/button"; -import { getStoreName } from "@/lib/store"; +import type { + HomepageActionIcon, + HomepageHeroSectionConfig, +} from "@/lib/homepage"; interface HeroSectionProps { basePath: string; - locale: string; + section: HomepageHeroSectionConfig; } -export async function HeroSection({ basePath, locale }: HeroSectionProps) { - const t = await getTranslations({ - locale: locale as Locale, - namespace: "home", - }); - const storeName = getStoreName(); +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 getActionIcon(icon?: HomepageActionIcon) { + if (icon === "play") { + return ; + } + + if (icon === "arrow-right") { + return ( + + ); + } + + return null; +} - /* Demo-only: Remove for production. */ - const githubUrl = "https://github.com/spree/storefront"; - const quickstartUrl = - "https://spreecommerce.org/docs/developer/getting-started/quickstart"; +export async function HeroSection({ basePath, section }: HeroSectionProps) { + const theme = section.theme ?? {}; + const sectionStyle: CSSProperties = { + backgroundColor: theme.background, + color: theme.foreground, + }; + const mutedTextColor = theme.mutedForeground ?? "#6b7280"; + const cardBackground = theme.cardBackground ?? "#ffffff"; + const borderColor = theme.borderColor ?? "#e5e7eb"; + const accentColor = theme.accent ?? "#2563eb"; + const alignmentClass = + section.alignment === "center" + ? "items-center text-center mx-auto" + : "items-start text-left"; + const actionsLayoutClass = + section.actionsLayout === "stack" + ? "flex-col items-stretch sm:items-start" + : "flex-row flex-wrap items-center"; + const stats = section.stats ?? []; return ( -
-
-
-

- {t("welcome", { storeName })} -

-

- {t("heroDescription")} -

-
- - {/* Demo-only: Remove for production. */} - - + ) : null} + + {section.secondaryAction ? ( + + ) : null} +
+ ) : null} + + {stats.length > 0 ? ( +
+ {stats.map((stat, index) => ( +
+
+
{stat.value}
+
+ {stat.label} +
+
+ {index < stats.length - 1 ? ( +
+ ) : null} +
+ ))} +
+ ) : null} +
+ +
+
+
+ {section.media?.alt +
+ + {section.media?.floatingBadgeTitle ? ( +
+
+
+ {section.media.floatingBadgeTitle.slice(0, 2).toUpperCase()} +
+
+
+ {section.media.floatingBadgeTitle} +
+ {section.media.floatingBadgeLabel ? ( +
+ {section.media.floatingBadgeLabel} +
+ ) : null} +
+
+
+ ) : null} + + {section.media?.secondaryBadgeTitle ? ( +
- {t("quickstartGuide")} → - - +
+ {section.media.secondaryBadgeTitle} +
+ {section.media.secondaryBadgeLabel ? ( +
+ {section.media.secondaryBadgeLabel} +
+ ) : null} +
+ ) : null}
diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index f0cd61a0..fc0561dd 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -3,9 +3,12 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { POLICY_LINKS } from "@/lib/constants/policies"; import { getStoreDescription, getStoreName } from "@/lib/store"; - -const storeName = getStoreName(); -const storeDescription = getStoreDescription(); +import type { TenantConfig } from "@/lib/tenant"; +import { + resolveTenantBranding, + resolveTenantFooter, + resolveTenantNavigation, +} from "@/lib/tenant"; // Demo-only: Remove for production. const githubUrl = "https://github.com/spree/storefront"; @@ -17,15 +20,49 @@ interface FooterProps { rootCategories: Category[]; basePath: string; locale: Locale; + tenantConfig?: TenantConfig | null; } export async function Footer({ rootCategories, basePath, locale, + tenantConfig, }: FooterProps) { const t = await getTranslations({ locale, namespace: "footer" }); const tp = await getTranslations({ locale, namespace: "policies" }); + const branding = resolveTenantBranding(tenantConfig, { + name: getStoreName(), + description: getStoreDescription(), + }); + const navigation = resolveTenantNavigation(tenantConfig); + const footerConfig = resolveTenantFooter(tenantConfig, { + description: + t("description") || branding.description || getStoreDescription(), + resourceLinks: [ + { label: t("forkOnGithub"), href: githubUrl }, + { label: t("quickstartGuide"), href: quickstartUrl }, + { label: t("learnMore"), href: learnMoreUrl }, + ], + shopLinks: [ + ...navigation.footerLinks, + { label: t("allProducts"), href: `${basePath}/products` }, + ...rootCategories.map((category) => ({ + label: category.name, + href: `${basePath}/c/${category.permalink}`, + })), + ], + accountLinks: [ + { label: t("myAccount"), href: `${basePath}/account` }, + { label: t("orderHistory"), href: `${basePath}/account/orders` }, + { label: t("cart"), href: `${basePath}/cart` }, + ], + policyLinks: POLICY_LINKS.map((policy) => ({ + label: tp(policy.nameKey), + href: `${basePath}/policies/${policy.slug}`, + })), + showPolicies: true, + }); return (
@@ -34,37 +71,32 @@ export async function Footer({ {/* Demo-only: Remove for production. */} {/* Brand */}
- {storeName} + + {branding.name} +

- {t("description") || storeDescription} + {footerConfig.description}

- {/* Demo-only: Remove for production. */} -
- - {t("forkOnGithub")} → - - - {t("quickstartGuide")} - - - {t("learnMore")} - -
+ {footerConfig.resourceLinks.length > 0 && ( +
+ {footerConfig.resourceLinks.map((link, index) => ( + + {link.label} + {index === 0 ? " →" : ""} + + ))} +
+ )}
{/* Links */} @@ -73,21 +105,13 @@ export async function Footer({ {t("shop")}
    -
  • - - {t("allProducts")} - -
  • - {rootCategories.map((category) => ( -
  • + {footerConfig.shopLinks.map((link) => ( +
  • - {category.name} + {link.label}
  • ))} @@ -100,30 +124,16 @@ export async function Footer({ {t("account")}
      -
    • - - {t("myAccount")} - -
    • -
    • - - {t("orderHistory")} - -
    • -
    • - - {t("cart")} - -
    • + {footerConfig.accountLinks.map((link) => ( +
    • + + {link.label} + +
    • + ))}
@@ -132,24 +142,26 @@ export async function Footer({

{t("policies")}

-
    - {POLICY_LINKS.map((policy) => ( -
  • - - {tp(policy.nameKey)} - -
  • - ))} -
+ {footerConfig.showPolicies && ( +
    + {footerConfig.policyLinks.map((policy) => ( +
  • + + {policy.label} + +
  • + ))} +
+ )}

- © {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 ( {storeName} } rightStart={ -

- +
+ {navigation.headerLinks.length > 0 && ( + + )} +
+ +
} 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) => ( +
+
+

{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..a7921949 --- /dev/null +++ b/src/components/page-builder/ImageBannerSection.tsx @@ -0,0 +1,95 @@ +import { ArrowRight } from "lucide-react"; +import Link from "next/link"; +import type { CSSProperties } 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"]) { + 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) { + 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 ? ( + + ) : 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 ? ( +
+ +
+ ) : 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..ae76419f --- /dev/null +++ b/src/contexts/TenantContext.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { createContext, type ReactNode, useContext, useMemo } from "react"; +import type { TenantConfig } from "@/lib/tenant"; + +const TenantContext = createContext(null); + +export function TenantConfigProvider({ + children, + config, +}: { + children: ReactNode; + config: TenantConfig; +}) { + const value = useMemo(() => config, [config]); + + return ( + {children} + ); +} + +export function useTenantConfig(): TenantConfig { + const context = useContext(TenantContext); + if (!context) { + throw new Error( + "useTenantConfig must be used within a TenantConfigProvider", + ); + } + return context; +} + +export function useTenantTheme() { + return useTenantConfig().theme; +} + +export function useTenantNavigation() { + return useTenantConfig().navigation; +} + +export function useTenantPayments() { + return useTenantConfig().paymentKeys; +} + +export function useTenantSpree() { + 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..5ed5c40f --- /dev/null +++ b/src/contexts/__tests__/TenantContext.test.tsx @@ -0,0 +1,57 @@ +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", + }, + paymentKeys: { + stripePublishableKey: "pk_test_123", + }, + theme: { + colors: { + primary: "#111111", + }, + }, + seo: {}, + navigation: { + links: [{ label: "Products", href: "/products" }], + }, + raw: {}, + source: "olitt", + fetchedAt: new Date().toISOString(), +} 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..cbfcb300 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,35 @@ export async function cachedListProducts( params: ProductListParams | undefined, options: { locale?: string; country?: string }, _userToken?: string, + baseUrl?: string, + publishableKey?: string, + _spreeScope?: string, ) { "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) { 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,11 +113,21 @@ export async function cachedGetProduct( expand: string[], options: { locale?: string; country?: string }, _userToken?: string, + baseUrl?: string, + publishableKey?: string, + _spreeScope?: string, ) { "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( @@ -58,22 +136,49 @@ export async function getProduct( ) { 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, ) { "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) { 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..2fa648c7 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -37,7 +37,7 @@ 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"; } /** 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..a6a83cd0 --- /dev/null +++ b/src/lib/tenant/css-vars.ts @@ -0,0 +1,107 @@ +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..0ec5375b --- /dev/null +++ b/src/lib/tenant/normalize.ts @@ -0,0 +1,201 @@ +import type { 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 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(), + }; +} 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..3bb53237 --- /dev/null +++ b/src/lib/tenant/request.ts @@ -0,0 +1,13 @@ +"use server"; + +import { headers } from "next/headers"; +import { getTenantConfigByHost } from "@/lib/tenant"; + +export async function getTenantConfigFromRequest() { + const requestHeaders = await headers(); + const host = + requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host"); + + 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..96b38728 --- /dev/null +++ b/src/lib/tenant/resolvers.ts @@ -0,0 +1,275 @@ +import type { DynamicPageSectionConfig } from "@/lib/page-builder"; +import { + getTenantBrandName, + getTenantDescription, + getTenantLogoUrl, + getTenantNavigationLinks, + type TenantLink, +} from "./surface"; +import type { TenantConfig } 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?: TenantConfig | null) { + return getRecord(config?.raw); +} + +function getDesignConfig(config?: TenantConfig | null) { + return getRecord(getRawConfig(config)?.design); +} + +function getLayoutConfig(config?: TenantConfig | null) { + 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?: TenantConfig | 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?: TenantConfig | 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?: TenantConfig | 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?: TenantConfig | 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..43f16e80 --- /dev/null +++ b/src/lib/tenant/surface.ts @@ -0,0 +1,125 @@ +import type { TenantConfig } 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?: TenantConfig | null, +): string | undefined { + const branding = getRecord(config?.raw?.branding); + + return ( + config?.storeName || + getString(branding?.name) || + getString(branding?.tagline) || + undefined + ); +} + +export function getTenantDescription( + config?: TenantConfig | 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?: TenantConfig | null, +): string | undefined { + return ( + config?.storeUrl || + getString(getRecord(config?.seo)?.siteUrl) || + getString(getRecord(config?.raw)?.storeUrl) || + undefined + ); +} + +export function getTenantLogoUrl( + config?: TenantConfig | 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?: TenantConfig | 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?: TenantConfig | 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?: TenantConfig | 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..e0f8c4fe --- /dev/null +++ b/src/lib/tenant/types.ts @@ -0,0 +1,22 @@ +export interface TenantSpreeConfig { + apiUrl: string; + publishableKey: string; +} + +export interface TenantConfig { + 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; +} From e2b41ae96a0aa8c7c4888a82a6a30635cd56e2b8 Mon Sep 17 00:00:00 2001 From: kipsang Date: Fri, 15 May 2026 12:51:56 +0300 Subject: [PATCH 2/7] feat: implement multi-tenant support with enhanced request handling and loading states --- next.config.ts | 4 ++ .../[locale]/(storefront)/layout.tsx | 6 +-- src/app/[country]/[locale]/layout.tsx | 16 ++++---- src/app/[country]/[locale]/loading.tsx | 22 +++++++++++ src/components/home/FeaturesSection.tsx | 1 - src/components/home/HeroSection.tsx | 8 +++- src/contexts/TenantContext.tsx | 8 ++-- src/contexts/__tests__/TenantContext.test.tsx | 9 ++--- src/lib/tenant/css-vars.ts | 12 +++--- src/lib/tenant/normalize.ts | 27 +++++++++++++- src/lib/tenant/request.ts | 37 +++++++++++++++++-- src/lib/tenant/types.ts | 12 ++++++ 12 files changed, 126 insertions(+), 36 deletions(-) create mode 100644 src/app/[country]/[locale]/loading.tsx diff --git a/next.config.ts b/next.config.ts index ba0b4c03..6e77e0fc 100644 --- a/next.config.ts +++ b/next.config.ts @@ -57,6 +57,10 @@ const nextConfig: NextConfig = { hostname: "**.trycloudflare.com", pathname: "/rails/active_storage/**", }, + { + protocol: "https", + hostname: "images.unsplash.com", + }, ], }, }; diff --git a/src/app/[country]/[locale]/(storefront)/layout.tsx b/src/app/[country]/[locale]/(storefront)/layout.tsx index 4310874a..b61ad28c 100644 --- a/src/app/[country]/[locale]/(storefront)/layout.tsx +++ b/src/app/[country]/[locale]/(storefront)/layout.tsx @@ -5,6 +5,7 @@ import { Footer } from "@/components/layout/Footer"; import { Header } from "@/components/layout/Header"; import { getCategories } from "@/lib/data/categories"; import { getTenantConfigByHost } from "@/lib/tenant"; +import { getRequestHost } from "@/lib/tenant/request"; interface StorefrontLayoutProps { children: React.ReactNode; @@ -41,10 +42,7 @@ export default async function StorefrontLayout({ const { country, locale } = await params; const basePath = `/${country}/${locale}`; const requestHeaders = await headers(); - const tenantHost = - requestHeaders.get("x-forwarded-host") ?? - requestHeaders.get("host") ?? - "localhost"; + const tenantHost = getRequestHost(requestHeaders) ?? "localhost"; const tenantConfig = await getTenantConfigByHost(tenantHost); const rootCategories = await getCategories({ diff --git a/src/app/[country]/[locale]/layout.tsx b/src/app/[country]/[locale]/layout.tsx index 28d7e012..441b2442 100644 --- a/src/app/[country]/[locale]/layout.tsx +++ b/src/app/[country]/[locale]/layout.tsx @@ -17,12 +17,17 @@ import { buildOrganizationJsonLd } from "@/lib/seo"; import { getDefaultCountry, getDefaultLocale } from "@/lib/store"; import { getTenantConfigByHost } from "@/lib/tenant"; import { buildCssVars } from "@/lib/tenant/css-vars"; -import { getTenantConfigFromRequest } from "@/lib/tenant/request"; +import { toPublicTenantConfig } from "@/lib/tenant/normalize"; +import { + getRequestHost, + getTenantConfigFromRequest, +} from "@/lib/tenant/request"; import deMessages from "../../../../messages/de.json"; import enMessages from "../../../../messages/en.json"; import esMessages from "../../../../messages/es.json"; import frMessages from "../../../../messages/fr.json"; import plMessages from "../../../../messages/pl.json"; +import LocaleLayoutLoading from "./loading"; const messagesMap: Record = { en: enMessages, @@ -73,7 +78,7 @@ export default async function CountryLocaleLayout({ params, }: CountryLocaleLayoutProps) { return ( - + }> {children} @@ -87,10 +92,7 @@ async function CountryLocaleLayoutInner({ }: CountryLocaleLayoutProps) { const { country, locale } = await params; const requestHeaders = await headers(); - const host = - requestHeaders.get("x-forwarded-host") ?? - requestHeaders.get("host") ?? - "localhost"; + const host = getRequestHost(requestHeaders) ?? "localhost"; const tenantConfig = await getTenantConfigByHost(host); if (!tenantConfig) { return ; @@ -135,7 +137,7 @@ async function CountryLocaleLayoutInner({ data-tenant-host={tenantConfig.host} data-tenant-id={tenantConfig.tenantId} > - + +
+
+

+ Loading {storeName} +

+
+
+ ); +} diff --git a/src/components/home/FeaturesSection.tsx b/src/components/home/FeaturesSection.tsx index 28fe466a..08b51efb 100644 --- a/src/components/home/FeaturesSection.tsx +++ b/src/components/home/FeaturesSection.tsx @@ -14,7 +14,6 @@ function getFeatureIcon(iconName?: string) { return ; case "shopping-bag": return ; - case "sparkles": default: return ; } diff --git a/src/components/home/HeroSection.tsx b/src/components/home/HeroSection.tsx index 251a0816..1dba2f59 100644 --- a/src/components/home/HeroSection.tsx +++ b/src/components/home/HeroSection.tsx @@ -1,4 +1,5 @@ import { ArrowRight, Play } from "lucide-react"; +import Image from "next/image"; import Link from "next/link"; import type { CSSProperties } from "react"; import { Button } from "@/components/ui/button"; @@ -178,13 +179,16 @@ export async function HeroSection({ basePath, section }: HeroSectionProps) { style={{ backgroundColor: cardBackground }} >
- {section.media?.alt
diff --git a/src/contexts/TenantContext.tsx b/src/contexts/TenantContext.tsx index ae76419f..877a7f92 100644 --- a/src/contexts/TenantContext.tsx +++ b/src/contexts/TenantContext.tsx @@ -1,16 +1,16 @@ "use client"; import { createContext, type ReactNode, useContext, useMemo } from "react"; -import type { TenantConfig } from "@/lib/tenant"; +import type { PublicTenantConfig } from "@/lib/tenant"; -const TenantContext = createContext(null); +const TenantContext = createContext(null); export function TenantConfigProvider({ children, config, }: { children: ReactNode; - config: TenantConfig; + config: PublicTenantConfig; }) { const value = useMemo(() => config, [config]); @@ -19,7 +19,7 @@ export function TenantConfigProvider({ ); } -export function useTenantConfig(): TenantConfig { +export function useTenantConfig(): PublicTenantConfig { const context = useContext(TenantContext); if (!context) { throw new Error( diff --git a/src/contexts/__tests__/TenantContext.test.tsx b/src/contexts/__tests__/TenantContext.test.tsx index 5ed5c40f..05f6fcba 100644 --- a/src/contexts/__tests__/TenantContext.test.tsx +++ b/src/contexts/__tests__/TenantContext.test.tsx @@ -13,9 +13,6 @@ const tenantConfig = { apiUrl: "https://spree.example.com", publishableKey: "pub-key", }, - paymentKeys: { - stripePublishableKey: "pk_test_123", - }, theme: { colors: { primary: "#111111", @@ -25,9 +22,9 @@ const tenantConfig = { navigation: { links: [{ label: "Products", href: "/products" }], }, - raw: {}, - source: "olitt", - fetchedAt: new Date().toISOString(), + paymentKeys: { + stripePublishableKey: "pk_test_123", + }, } as never; function wrapper({ children }: { children: React.ReactNode }) { diff --git a/src/lib/tenant/css-vars.ts b/src/lib/tenant/css-vars.ts index a6a83cd0..c5f6f23b 100644 --- a/src/lib/tenant/css-vars.ts +++ b/src/lib/tenant/css-vars.ts @@ -67,15 +67,13 @@ export function resolveTenantThemeConfig( export function buildCssVars(config: TenantConfig): Record { const theme = resolveTenantThemeConfig(config); - const branding = getNestedRecord(theme["branding"]); + const branding = getNestedRecord(theme.branding); const colors = - getNestedRecord(theme["colors"]) ?? - getNestedRecord(branding?.["colors"]) ?? - {}; - const fonts = getNestedRecord(theme["fonts"]) ?? {}; + getNestedRecord(theme.colors) ?? getNestedRecord(branding?.colors) ?? {}; + const fonts = getNestedRecord(theme.fonts) ?? {}; - const spacing = getString(theme["spacing"]); - const radius = getString(theme["borderRadius"]); + const spacing = getString(theme.spacing); + const radius = getString(theme.borderRadius); const spacingPreset = SPACING_MAP[(spacing as keyof typeof SPACING_MAP) ?? "comfortable"] ?? SPACING_MAP.comfortable; diff --git a/src/lib/tenant/normalize.ts b/src/lib/tenant/normalize.ts index 0ec5375b..1712bd2d 100644 --- a/src/lib/tenant/normalize.ts +++ b/src/lib/tenant/normalize.ts @@ -1,4 +1,8 @@ -import type { TenantConfig } from "./types"; +import type { + PublicTenantConfig, + PublicTenantPaymentKeys, + TenantConfig, +} from "./types"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -150,6 +154,17 @@ function collectPaymentKeys( 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; @@ -199,3 +214,13 @@ export function buildTenantConfigFromRecord( 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/request.ts b/src/lib/tenant/request.ts index 3bb53237..7d890ee4 100644 --- a/src/lib/tenant/request.ts +++ b/src/lib/tenant/request.ts @@ -1,12 +1,41 @@ -"use server"; - 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; + if (value.includes(",")) return null; + return normalizeHost(value, { preservePort: true }); +} + +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 = - requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host"); + const host = getRequestHost(requestHeaders); if (!host) return null; return getTenantConfigByHost(host); diff --git a/src/lib/tenant/types.ts b/src/lib/tenant/types.ts index e0f8c4fe..5c555d82 100644 --- a/src/lib/tenant/types.ts +++ b/src/lib/tenant/types.ts @@ -3,6 +3,18 @@ export interface TenantSpreeConfig { publishableKey: string; } +export interface PublicTenantPaymentKeys { + stripePublishableKey?: string; +} + +export interface PublicTenantConfig { + storeName: string; + spree: TenantSpreeConfig; + paymentKeys: PublicTenantPaymentKeys; + theme: Record; + navigation: Record; +} + export interface TenantConfig { tenantId: string; host: string; From b7c55e7c9b9c86e3d686b6198d4bf5cc126f61b2 Mon Sep 17 00:00:00 2001 From: kipsang Date: Fri, 15 May 2026 13:06:15 +0300 Subject: [PATCH 3/7] feat: update tenant configuration to use TenantSurfaceConfig for improved type safety --- src/lib/tenant/resolvers.ts | 16 ++++++++-------- src/lib/tenant/surface.ts | 18 ++++++++++-------- src/lib/tenant/types.ts | 14 ++++++++++++-- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/lib/tenant/resolvers.ts b/src/lib/tenant/resolvers.ts index 96b38728..cac0895b 100644 --- a/src/lib/tenant/resolvers.ts +++ b/src/lib/tenant/resolvers.ts @@ -6,7 +6,7 @@ import { getTenantNavigationLinks, type TenantLink, } from "./surface"; -import type { TenantConfig } from "./types"; +import type { TenantConfig, TenantSurfaceConfig } from "./types"; function getRecord(value: unknown): Record | undefined { if (value && typeof value === "object" && !Array.isArray(value)) { @@ -67,15 +67,15 @@ function getSections(value: unknown): DynamicPageSectionConfig[] { return getArray(value).filter(isDynamicPageSection); } -function getRawConfig(config?: TenantConfig | null) { +function getRawConfig(config?: TenantSurfaceConfig | null) { return getRecord(config?.raw); } -function getDesignConfig(config?: TenantConfig | null) { +function getDesignConfig(config?: TenantSurfaceConfig | null) { return getRecord(getRawConfig(config)?.design); } -function getLayoutConfig(config?: TenantConfig | null) { +function getLayoutConfig(config?: TenantSurfaceConfig | null) { return getRecord(getDesignConfig(config)?.layout); } @@ -111,7 +111,7 @@ export interface TenantFixedPageSlotsConfig { } export function resolveTenantBranding( - config?: TenantConfig | null, + config?: TenantSurfaceConfig | null, defaults: TenantBrandingConfig = {}, ): TenantBrandingConfig { const raw = getRawConfig(config); @@ -145,7 +145,7 @@ export function resolveTenantBranding( } export function resolveTenantNavigation( - config?: TenantConfig | null, + config?: TenantSurfaceConfig | null, defaults: Partial = {}, ): TenantNavigationConfig { const raw = getRawConfig(config); @@ -184,7 +184,7 @@ export function resolveTenantNavigation( } export function resolveTenantFooter( - config?: TenantConfig | null, + config?: TenantSurfaceConfig | null, defaults: Partial = {}, ): TenantFooterConfig { const raw = getRawConfig(config); @@ -234,7 +234,7 @@ export function resolveTenantFooter( } export function resolveTenantFixedPageSlots( - config?: TenantConfig | null, + config?: TenantSurfaceConfig | null, defaults: Partial = {}, ): TenantFixedPageSlotsConfig { const raw = getRawConfig(config); diff --git a/src/lib/tenant/surface.ts b/src/lib/tenant/surface.ts index 43f16e80..bfe0eed2 100644 --- a/src/lib/tenant/surface.ts +++ b/src/lib/tenant/surface.ts @@ -1,4 +1,4 @@ -import type { TenantConfig } from "./types"; +import type { TenantSurfaceConfig } from "./types"; export interface TenantLink { label: string; href: string; @@ -20,7 +20,7 @@ function getArray(value: unknown): unknown[] { } export function getTenantBrandName( - config?: TenantConfig | null, + config?: TenantSurfaceConfig | null, ): string | undefined { const branding = getRecord(config?.raw?.branding); @@ -33,7 +33,7 @@ export function getTenantBrandName( } export function getTenantDescription( - config?: TenantConfig | null, + config?: TenantSurfaceConfig | null, ): string | undefined { const branding = getRecord(config?.raw?.branding); @@ -46,7 +46,7 @@ export function getTenantDescription( } export function getTenantSiteUrl( - config?: TenantConfig | null, + config?: TenantSurfaceConfig | null, ): string | undefined { return ( config?.storeUrl || @@ -57,7 +57,7 @@ export function getTenantSiteUrl( } export function getTenantLogoUrl( - config?: TenantConfig | null, + config?: TenantSurfaceConfig | null, ): string | undefined { const branding = getRecord(config?.raw?.branding); const theme = getRecord(config?.theme); @@ -77,7 +77,7 @@ export function getTenantLogoUrl( } export function getTenantTwitterHandle( - config?: TenantConfig | null, + config?: TenantSurfaceConfig | null, ): string | undefined { const seo = getRecord(config?.seo); const raw = getRecord(config?.raw); @@ -90,7 +90,9 @@ export function getTenantTwitterHandle( ); } -export function getTenantSocialLinks(config?: TenantConfig | null): string[] { +export function getTenantSocialLinks( + config?: TenantSurfaceConfig | null, +): string[] { const branding = getRecord(config?.raw?.branding); const seo = getRecord(config?.seo); const raw = getRecord(config?.raw); @@ -107,7 +109,7 @@ export function getTenantSocialLinks(config?: TenantConfig | null): string[] { } export function getTenantNavigationLinks( - config?: TenantConfig | null, + config?: TenantSurfaceConfig | null, ): TenantLink[] { const navigation = getRecord(config?.navigation); const links = getArray(navigation?.links); diff --git a/src/lib/tenant/types.ts b/src/lib/tenant/types.ts index 5c555d82..ff425b90 100644 --- a/src/lib/tenant/types.ts +++ b/src/lib/tenant/types.ts @@ -3,11 +3,21 @@ export interface TenantSpreeConfig { 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 { +export interface PublicTenantConfig extends TenantSurfaceConfig { storeName: string; spree: TenantSpreeConfig; paymentKeys: PublicTenantPaymentKeys; @@ -15,7 +25,7 @@ export interface PublicTenantConfig { navigation: Record; } -export interface TenantConfig { +export interface TenantConfig extends TenantSurfaceConfig { tenantId: string; host: string; storeName: string; From b48f00299ccba55e3d0af688a742e7b32448ef81 Mon Sep 17 00:00:00 2001 From: kipsang Date: Fri, 15 May 2026 13:15:20 +0300 Subject: [PATCH 4/7] feat: enhance type safety across components and functions with explicit return types --- .../[country]/[locale]/(storefront)/page.tsx | 9 +++++++-- .../page-builder/ImageBannerSection.tsx | 8 +++++--- src/contexts/TenantContext.tsx | 18 ++++++++++++------ src/lib/data/products.ts | 16 ++++++++++------ src/lib/tenant/resolvers.ts | 2 +- 5 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/app/[country]/[locale]/(storefront)/page.tsx b/src/app/[country]/[locale]/(storefront)/page.tsx index da5762da..cd434d1b 100644 --- a/src/app/[country]/[locale]/(storefront)/page.tsx +++ b/src/app/[country]/[locale]/(storefront)/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import type { ReactElement } from "react"; import { FeaturedProductsSection } from "@/components/home/FeaturedProductsSection"; import { FeaturesSection } from "@/components/home/FeaturesSection"; import { HeroSection } from "@/components/home/HeroSection"; @@ -25,7 +26,9 @@ interface HomePageProps { * always include the store's configured default country/locale as a * fallback even if the markets fetch fails. */ -export async function generateStaticParams() { +export async function generateStaticParams(): Promise< + Array<{ country: string; locale: string }> +> { const fallback = { country: getDefaultCountry(), locale: getDefaultLocale(), @@ -72,7 +75,9 @@ export async function generateMetadata({ return generateHomeMetadata({ country, locale }); } -export default async function HomePage({ params }: HomePageProps) { +export default async function HomePage({ + params, +}: HomePageProps): Promise { const { country, locale } = await params; const basePath = `/${country}/${locale}`; const currency = await resolveCurrency(country); diff --git a/src/components/page-builder/ImageBannerSection.tsx b/src/components/page-builder/ImageBannerSection.tsx index a7921949..0235761e 100644 --- a/src/components/page-builder/ImageBannerSection.tsx +++ b/src/components/page-builder/ImageBannerSection.tsx @@ -1,6 +1,6 @@ import { ArrowRight } from "lucide-react"; import Link from "next/link"; -import type { CSSProperties } from "react"; +import type { CSSProperties, ReactElement } from "react"; import { Button } from "@/components/ui/button"; import type { DynamicPageImageBannerSectionConfig } from "@/lib/page-builder"; @@ -16,7 +16,9 @@ function resolveHref(basePath: string, href: string): string { return href.startsWith("/") ? `${basePath}${href}` : `${basePath}/${href}`; } -function getHeightClass(height: DynamicPageImageBannerSectionConfig["height"]) { +function getHeightClass( + height: DynamicPageImageBannerSectionConfig["height"], +): string { switch (height) { case "sm": return "min-h-[320px]"; @@ -30,7 +32,7 @@ function getHeightClass(height: DynamicPageImageBannerSectionConfig["height"]) { export function ImageBannerSection({ basePath, section, -}: ImageBannerSectionProps) { +}: ImageBannerSectionProps): ReactElement { const theme = section.theme ?? {}; const foreground = theme.foreground ?? "#ffffff"; const mutedTextColor = theme.mutedForeground ?? "#e5e7eb"; diff --git a/src/contexts/TenantContext.tsx b/src/contexts/TenantContext.tsx index 877a7f92..e1ad9b27 100644 --- a/src/contexts/TenantContext.tsx +++ b/src/contexts/TenantContext.tsx @@ -1,6 +1,12 @@ "use client"; -import { createContext, type ReactNode, useContext, useMemo } from "react"; +import { + createContext, + type ReactElement, + type ReactNode, + useContext, + useMemo, +} from "react"; import type { PublicTenantConfig } from "@/lib/tenant"; const TenantContext = createContext(null); @@ -11,7 +17,7 @@ export function TenantConfigProvider({ }: { children: ReactNode; config: PublicTenantConfig; -}) { +}): ReactElement { const value = useMemo(() => config, [config]); return ( @@ -29,18 +35,18 @@ export function useTenantConfig(): PublicTenantConfig { return context; } -export function useTenantTheme() { +export function useTenantTheme(): PublicTenantConfig["theme"] { return useTenantConfig().theme; } -export function useTenantNavigation() { +export function useTenantNavigation(): PublicTenantConfig["navigation"] { return useTenantConfig().navigation; } -export function useTenantPayments() { +export function useTenantPayments(): PublicTenantConfig["paymentKeys"] { return useTenantConfig().paymentKeys; } -export function useTenantSpree() { +export function useTenantSpree(): PublicTenantConfig["spree"] { return useTenantConfig().spree; } diff --git a/src/lib/data/products.ts b/src/lib/data/products.ts index cbfcb300..f57f12b1 100644 --- a/src/lib/data/products.ts +++ b/src/lib/data/products.ts @@ -71,7 +71,7 @@ export async function cachedListProducts( baseUrl?: string, publishableKey?: string, _spreeScope?: string, -) { +): Promise> { "use cache: remote"; cacheLife("tenMinutes"); cacheTag("products"); @@ -85,7 +85,9 @@ export async function cachedListProducts( }).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(); const spreeConfig = await resolveSpreeConfig(); @@ -116,7 +118,7 @@ export async function cachedGetProduct( baseUrl?: string, publishableKey?: string, _spreeScope?: string, -) { +): Promise { "use cache: remote"; cacheLife("tenMinutes"); cacheTag("products", `product:${slugOrId}`); @@ -133,7 +135,7 @@ export async function cachedGetProduct( export async function getProduct( slugOrId: string, params?: { expand?: string[] }, -) { +): Promise { const options = await getLocaleOptions(); const userToken = await getAccessToken(); const spreeConfig = await resolveSpreeConfig(); @@ -155,7 +157,7 @@ async function cachedGetProductFilters( baseUrl?: string, publishableKey?: string, _spreeScope?: string, -) { +): Promise { "use cache: remote"; cacheLife("tenMinutes"); cacheTag("product-filters"); @@ -169,7 +171,9 @@ async function cachedGetProductFilters( }).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(); const spreeConfig = await resolveSpreeConfig(); diff --git a/src/lib/tenant/resolvers.ts b/src/lib/tenant/resolvers.ts index cac0895b..d7eefe57 100644 --- a/src/lib/tenant/resolvers.ts +++ b/src/lib/tenant/resolvers.ts @@ -6,7 +6,7 @@ import { getTenantNavigationLinks, type TenantLink, } from "./surface"; -import type { TenantConfig, TenantSurfaceConfig } from "./types"; +import type { TenantSurfaceConfig } from "./types"; function getRecord(value: unknown): Record | undefined { if (value && typeof value === "object" && !Array.isArray(value)) { From 3ffc2676509aa6271cd6e9143d0e821812a457f1 Mon Sep 17 00:00:00 2001 From: kipsang Date: Fri, 15 May 2026 14:18:51 +0300 Subject: [PATCH 5/7] feat: simplify CartProvider usage by removing fallback component in Suspense --- src/app/layout.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c969aa1b..69277513 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,7 +5,7 @@ import type { Metadata } from "next"; import { Geist } from "next/font/google"; import { Suspense } from "react"; -import { CartProvider, CartProviderFallback } from "@/contexts/CartContext"; +import { CartProvider } from "@/contexts/CartContext"; import { getStoreDescription, getStoreName } from "@/lib/store"; import "./globals.css"; @@ -57,9 +57,7 @@ export default async function RootLayout({ className={`${geist.variable} antialiased min-h-screen flex flex-col`} suppressHydrationWarning > - {children}} - > + }> {children} From 9e24bb47b245f162537106c6406bfc315d7a9bbc Mon Sep 17 00:00:00 2001 From: kipsang Date: Fri, 15 May 2026 15:11:39 +0300 Subject: [PATCH 6/7] feat: update branding references to use Olitt and enhance type safety in tenant configuration functions --- messages/en.json | 2 +- src/app/[country]/[locale]/loading.tsx | 3 ++- src/components/layout/Footer.tsx | 6 +----- src/lib/store.ts | 9 +++------ src/lib/tenant/request.ts | 9 +++++++-- src/lib/tenant/resolvers.ts | 12 +++++++++--- 6 files changed, 23 insertions(+), 18 deletions(-) diff --git a/messages/en.json b/messages/en.json index c2be4715..01103cc4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -53,7 +53,7 @@ "cart": "Cart", "policies": "Policies", "poweredBy": "Powered by", - "copyright": "© {year} {storeName}. Powered by Spree Commerce." + "copyright": "© {year} {storeName}. Powered by Olitt" }, "home": { "welcome": "{storeName} Storefront", diff --git a/src/app/[country]/[locale]/loading.tsx b/src/app/[country]/[locale]/loading.tsx index 8be683d5..3e0b10db 100644 --- a/src/app/[country]/[locale]/loading.tsx +++ b/src/app/[country]/[locale]/loading.tsx @@ -1,9 +1,10 @@ import { headers } from "next/headers"; +import type { ReactElement } from "react"; import { getTenantConfigByHost } from "@/lib/tenant/olitt"; import { getRequestHost } from "@/lib/tenant/request"; import { getTenantBrandName } from "@/lib/tenant/surface"; -export default async function Loading() { +export default async function Loading(): Promise { const requestHeaders = await headers(); const host = getRequestHost(requestHeaders) ?? ""; const tenantConfig = host ? await getTenantConfigByHost(host) : null; diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index fc0561dd..c058a457 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -39,11 +39,7 @@ export async function Footer({ const footerConfig = resolveTenantFooter(tenantConfig, { description: t("description") || branding.description || getStoreDescription(), - resourceLinks: [ - { label: t("forkOnGithub"), href: githubUrl }, - { label: t("quickstartGuide"), href: quickstartUrl }, - { label: t("learnMore"), href: learnMoreUrl }, - ], + resourceLinks: [], shopLinks: [ ...navigation.footerLinks, { label: t("allProducts"), href: `${basePath}/products` }, diff --git a/src/lib/store.ts b/src/lib/store.ts index 2fa648c7..7627d1b2 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -44,10 +44,7 @@ export function getStoreName(): string { * 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/request.ts b/src/lib/tenant/request.ts index 7d890ee4..984e7bd0 100644 --- a/src/lib/tenant/request.ts +++ b/src/lib/tenant/request.ts @@ -16,8 +16,13 @@ function isTrustedProxyEnabled(): boolean { function normalizeHeaderHost(value: string | null): string | null { if (!value) return null; - if (value.includes(",")) return null; - return normalizeHost(value, { preservePort: true }); + + 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 { diff --git a/src/lib/tenant/resolvers.ts b/src/lib/tenant/resolvers.ts index d7eefe57..7a50e24c 100644 --- a/src/lib/tenant/resolvers.ts +++ b/src/lib/tenant/resolvers.ts @@ -67,15 +67,21 @@ function getSections(value: unknown): DynamicPageSectionConfig[] { return getArray(value).filter(isDynamicPageSection); } -function getRawConfig(config?: TenantSurfaceConfig | null) { +function getRawConfig( + config?: TenantSurfaceConfig | null, +): Record | undefined { return getRecord(config?.raw); } -function getDesignConfig(config?: TenantSurfaceConfig | null) { +function getDesignConfig( + config?: TenantSurfaceConfig | null, +): Record | undefined { return getRecord(getRawConfig(config)?.design); } -function getLayoutConfig(config?: TenantSurfaceConfig | null) { +function getLayoutConfig( + config?: TenantSurfaceConfig | null, +): Record | undefined { return getRecord(getDesignConfig(config)?.layout); } From 5fd80426ca850506e205f9affc6f01cd73a5bfba Mon Sep 17 00:00:00 2001 From: kipsang Date: Fri, 15 May 2026 15:11:55 +0300 Subject: [PATCH 7/7] feat: add support for Olitt storage in Next.js configuration --- next.config.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/next.config.ts b/next.config.ts index 6e77e0fc..5360decd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -57,6 +57,16 @@ const nextConfig: NextConfig = { hostname: "**.trycloudflare.com", pathname: "/rails/active_storage/**", }, + { + protocol: "https", + hostname: "olitt.store", + pathname: "/rails/active_storage/**", + }, + { + protocol: "https", + hostname: "**.olitt.store", + pathname: "/rails/active_storage/**", + }, { protocol: "https", hostname: "images.unsplash.com",