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]/(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/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/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 ae76419f..e1ad9b27 100644 --- a/src/contexts/TenantContext.tsx +++ b/src/contexts/TenantContext.tsx @@ -1,17 +1,23 @@ "use client"; -import { createContext, type ReactNode, useContext, useMemo } from "react"; -import type { TenantConfig } from "@/lib/tenant"; +import { + createContext, + type ReactElement, + type ReactNode, + useContext, + useMemo, +} from "react"; +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; +}): ReactElement { const value = useMemo(() => config, [config]); return ( @@ -19,7 +25,7 @@ export function TenantConfigProvider({ ); } -export function useTenantConfig(): TenantConfig { +export function useTenantConfig(): PublicTenantConfig { const context = useContext(TenantContext); if (!context) { throw new Error( @@ -29,18 +35,18 @@ export function useTenantConfig(): TenantConfig { 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/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/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/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/resolvers.ts b/src/lib/tenant/resolvers.ts index 96b38728..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 } from "./types"; +import type { 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 e0f8c4fe..ff425b90 100644 --- a/src/lib/tenant/types.ts +++ b/src/lib/tenant/types.ts @@ -3,7 +3,29 @@ export interface TenantSpreeConfig { publishableKey: string; } -export interface TenantConfig { +export interface TenantSurfaceConfig { + storeName?: string; + storeDescription?: string; + storeUrl?: string; + theme?: Record; + seo?: Record; + navigation?: Record; + raw?: Record; +} + +export interface PublicTenantPaymentKeys { + stripePublishableKey?: string; +} + +export interface PublicTenantConfig extends TenantSurfaceConfig { + storeName: string; + spree: TenantSpreeConfig; + paymentKeys: PublicTenantPaymentKeys; + theme: Record; + navigation: Record; +} + +export interface TenantConfig extends TenantSurfaceConfig { tenantId: string; host: string; storeName: string;